mirror of
https://github.com/Kingsrook/qqq-frontend-material-dashboard.git
synced 2025-07-19 05:40:44 +00:00
First pass at permissions; Updated auth0 to work with access token instead of id token
This commit is contained in:
@ -125,7 +125,7 @@ function AppHome({app}: Props): JSX.Element
|
||||
{
|
||||
const tableMetaData = await qController.loadTableMetaData(table.name);
|
||||
let countResult = null;
|
||||
if(tableMetaData.capabilities.has(Capability.TABLE_COUNT))
|
||||
if(tableMetaData.capabilities.has(Capability.TABLE_COUNT) && tableMetaData.readPermission)
|
||||
{
|
||||
countResult = await qController.count(table.name);
|
||||
|
||||
@ -183,6 +183,20 @@ function AppHome({app}: Props): JSX.Element
|
||||
}, 1);
|
||||
};
|
||||
|
||||
const hasTablePermission = (tableName: string) =>
|
||||
{
|
||||
return tables.find(t => t.name === tableName && (t.readPermission || t.insertPermission || t.editPermission || t.deletePermission));
|
||||
};
|
||||
|
||||
const hasProcessPermission = (processName: string) =>
|
||||
{
|
||||
return processes.find(p => p.name === processName && p.hasPermission);
|
||||
};
|
||||
|
||||
const hasReportPermission = (reportName: string) =>
|
||||
{
|
||||
return reports.find(r => r.name === reportName && r.hasPermission);
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseLayout>
|
||||
@ -210,7 +224,7 @@ function AppHome({app}: Props): JSX.Element
|
||||
</Box>
|
||||
{
|
||||
section.processes ? (
|
||||
<Box p={3} pl={5} pt={0}>
|
||||
<Box p={3} pl={5} pt={0} pb={1}>
|
||||
<MDTypography variant="h6">Actions</MDTypography>
|
||||
</Box>
|
||||
) : null
|
||||
@ -224,12 +238,19 @@ function AppHome({app}: Props): JSX.Element
|
||||
let process = app.childMap.get(processName);
|
||||
return (
|
||||
<Grid key={process.name} item xs={12} md={12} lg={tileSizeLg}>
|
||||
<Link to={process.name}>
|
||||
{hasProcessPermission(processName) ?
|
||||
<Link to={process.name}>
|
||||
<ProcessLinkCard
|
||||
icon={process.iconName || app.iconName}
|
||||
title={process.label}
|
||||
/>
|
||||
</Link> :
|
||||
<ProcessLinkCard
|
||||
icon={process.iconName || app.iconName}
|
||||
title={process.label}
|
||||
isDisabled={true}
|
||||
/>
|
||||
</Link>
|
||||
}
|
||||
</Grid>
|
||||
);
|
||||
})
|
||||
@ -244,7 +265,7 @@ function AppHome({app}: Props): JSX.Element
|
||||
}
|
||||
{
|
||||
section.reports ? (
|
||||
<Box p={3} pl={5} pt={0}>
|
||||
<Box p={3} pl={5} pt={0} pb={1}>
|
||||
<MDTypography variant="h6">Reports</MDTypography>
|
||||
</Box>
|
||||
) : null
|
||||
@ -258,13 +279,21 @@ function AppHome({app}: Props): JSX.Element
|
||||
let report = app.childMap.get(reportName);
|
||||
return (
|
||||
<Grid key={report.name} item xs={12} md={12} lg={tileSizeLg}>
|
||||
<Link to={report.name}>
|
||||
{hasReportPermission(reportName) ?
|
||||
<Link to={report.name}>
|
||||
<ProcessLinkCard
|
||||
icon={report.iconName || app.iconName}
|
||||
title={report.label}
|
||||
isReport={true}
|
||||
/>
|
||||
</Link> :
|
||||
<ProcessLinkCard
|
||||
icon={report.iconName || app.iconName}
|
||||
title={report.label}
|
||||
isReport={true}
|
||||
isDisabled={true}
|
||||
/>
|
||||
</Link>
|
||||
}
|
||||
</Grid>
|
||||
);
|
||||
})
|
||||
@ -279,7 +308,7 @@ function AppHome({app}: Props): JSX.Element
|
||||
}
|
||||
{
|
||||
section.tables ? (
|
||||
<Box p={3} pl={5} pb={0} pt={0}>
|
||||
<Box p={3} pl={5} pb={1} pt={0}>
|
||||
<MDTypography variant="h6">Data</MDTypography>
|
||||
</Box>
|
||||
) : null
|
||||
@ -293,16 +322,27 @@ function AppHome({app}: Props): JSX.Element
|
||||
let table = app.childMap.get(tableName);
|
||||
return (
|
||||
<Grid key={table.name} item xs={12} md={12} lg={tileSizeLg}>
|
||||
<Link to={table.name}>
|
||||
<Box mb={3}>
|
||||
{hasTablePermission(tableName) ?
|
||||
<Link to={table.name}>
|
||||
<Box mb={3}>
|
||||
<MiniStatisticsCard
|
||||
title={{fontWeight: "bold", text: table.label}}
|
||||
count={!tableCounts.has(table.name) || tableCounts.get(table.name).isLoading ? "..." : (tableCountNumbers.get(table.name))}
|
||||
percentage={{color: "info", text: (!tableCounts.has(table.name) || tableCounts.get(table.name).isLoading ? "" : (tableCountTexts.get(table.name)))}}
|
||||
icon={{color: "info", component: <Icon>{table.iconName || app.iconName}</Icon>}}
|
||||
/>
|
||||
</Box>
|
||||
</Link> :
|
||||
<Box mb={3} title="You do not have permission to access this table">
|
||||
<MiniStatisticsCard
|
||||
title={{fontWeight: "bold", text: table.label}}
|
||||
count={!tableCounts.has(table.name) || tableCounts.get(table.name).isLoading ? "..." : (tableCountNumbers.get(table.name))}
|
||||
percentage={{color: "info", text: (!tableCounts.has(table.name) || tableCounts.get(table.name).isLoading ? "" : (tableCountTexts.get(table.name)))}}
|
||||
icon={{color: "info", component: <Icon>{table.iconName || app.iconName}</Icon>}}
|
||||
isDisabled={true}
|
||||
/>
|
||||
</Box>
|
||||
</Link>
|
||||
}
|
||||
</Grid>
|
||||
);
|
||||
})
|
||||
|
23
src/qqq/pages/apps/NoApps.tsx
Normal file
23
src/qqq/pages/apps/NoApps.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import {Alert} from "@mui/material";
|
||||
import BaseLayout from "qqq/layouts/BaseLayout";
|
||||
|
||||
|
||||
interface Props
|
||||
{
|
||||
foo: string;
|
||||
}
|
||||
|
||||
NoApps.defaultProps = {
|
||||
foo: null,
|
||||
};
|
||||
|
||||
function NoApps({foo}: Props): JSX.Element
|
||||
{
|
||||
return (
|
||||
<BaseLayout>
|
||||
<Alert color="error">You do not have permission to access any apps.</Alert>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default NoApps;
|
@ -71,16 +71,18 @@ interface Props
|
||||
defaultProcessValues?: any;
|
||||
isModal?: boolean;
|
||||
isWidget?: boolean;
|
||||
isReport?: boolean;
|
||||
recordIds?: string | QQueryFilter;
|
||||
closeModalHandler?: (event: object, reason: string) => void;
|
||||
forceReInit?: number;
|
||||
overrideLabel?: string
|
||||
}
|
||||
|
||||
const INITIAL_RETRY_MILLIS = 1_500;
|
||||
const RETRY_MAX_MILLIS = 12_000;
|
||||
const BACKOFF_AMOUNT = 1.5;
|
||||
|
||||
function ProcessRun({process, defaultProcessValues, isModal, isWidget, recordIds, closeModalHandler, forceReInit}: Props): JSX.Element
|
||||
function ProcessRun({process, defaultProcessValues, isModal, isWidget, isReport, recordIds, closeModalHandler, forceReInit, overrideLabel}: Props): JSX.Element
|
||||
{
|
||||
const processNameParam = useParams().processName;
|
||||
const processName = process === null ? processNameParam : process.name;
|
||||
@ -236,9 +238,9 @@ function ProcessRun({process, defaultProcessValues, isModal, isWidget, recordIds
|
||||
Error
|
||||
</MDTypography>
|
||||
<MDTypography color="body" variant="button">
|
||||
An error occurred while running the process:
|
||||
An error occurred while running the {isReport ? "report" : "process"}:
|
||||
{" "}
|
||||
{process.label}
|
||||
{overrideLabel ?? process.label}
|
||||
{
|
||||
isUserFacingError ? (
|
||||
<Box mt={1}>
|
||||
@ -331,7 +333,7 @@ function ProcessRun({process, defaultProcessValues, isModal, isWidget, recordIds
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
!isWidget &&
|
||||
<MDTypography variant={isWidget ? "h6" : "h5"} component="div" fontWeight="bold">
|
||||
{(isModal) ? `${process.label}: ` : ""}
|
||||
{(isModal) ? `${overrideLabel ?? process.label}: ` : ""}
|
||||
{step?.label}
|
||||
</MDTypography>
|
||||
}
|
||||
@ -616,7 +618,7 @@ function ProcessRun({process, defaultProcessValues, isModal, isWidget, recordIds
|
||||
|
||||
if(! isWidget)
|
||||
{
|
||||
setPageHeader(processMetaData.label);
|
||||
setPageHeader(overrideLabel ?? processMetaData.label);
|
||||
}
|
||||
|
||||
let newIndex = null;
|
||||
@ -935,6 +937,18 @@ function ProcessRun({process, defaultProcessValues, isModal, isWidget, recordIds
|
||||
}
|
||||
}, [needToCheckJobStatus, retryMillis]);
|
||||
|
||||
|
||||
const handlePermissionDenied = (e: any): boolean =>
|
||||
{
|
||||
if ((e as QException).status === "403")
|
||||
{
|
||||
setProcessError(`You do not have permission to run this ${isReport ? "report" : "process"}.`, true)
|
||||
return (true);
|
||||
}
|
||||
return (false);
|
||||
}
|
||||
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////
|
||||
// do the initial load of data for the process - that is, meta data, plus the init step //
|
||||
// also - allow the component that contains this component to force a re-init, by //
|
||||
@ -1011,7 +1025,7 @@ function ProcessRun({process, defaultProcessValues, isModal, isWidget, recordIds
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
setProcessError("Error loading process definition.");
|
||||
handlePermissionDenied(e) || setProcessError("Error loading process definition.");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1031,7 +1045,7 @@ function ProcessRun({process, defaultProcessValues, isModal, isWidget, recordIds
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
setProcessError("Error initializing process.");
|
||||
handlePermissionDenied(e) || setProcessError("Error initializing process.");
|
||||
}
|
||||
})();
|
||||
}
|
||||
@ -1286,9 +1300,11 @@ ProcessRun.defaultProps = {
|
||||
defaultProcessValues: {},
|
||||
isModal: false,
|
||||
isWidget: false,
|
||||
isReport: false,
|
||||
recordIds: null,
|
||||
closeModalHandler: null,
|
||||
forceReInit: 0
|
||||
forceReInit: 0,
|
||||
overrideLabel: null,
|
||||
};
|
||||
|
||||
export default ProcessRun;
|
||||
|
@ -53,7 +53,7 @@ function ReportRun({report}: Props): JSX.Element
|
||||
setPageHeader(report.label);
|
||||
const process = metaData.processes.get(report.processName);
|
||||
const defaultProcessValues = {reportName: report.name};
|
||||
return (<ProcessRun process={process} defaultProcessValues={defaultProcessValues} />);
|
||||
return (<ProcessRun process={process} overrideLabel={report.label} isReport={true} defaultProcessValues={defaultProcessValues} />);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
@ -1044,27 +1044,27 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
||||
keepMounted
|
||||
>
|
||||
{
|
||||
table.capabilities.has(Capability.TABLE_INSERT) &&
|
||||
table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission &&
|
||||
<MenuItem onClick={bulkLoadClicked}>
|
||||
<ListItemIcon><Icon>library_add</Icon></ListItemIcon>
|
||||
Bulk Load
|
||||
</MenuItem>
|
||||
}
|
||||
{
|
||||
table.capabilities.has(Capability.TABLE_UPDATE) &&
|
||||
table.capabilities.has(Capability.TABLE_UPDATE) && table.editPermission &&
|
||||
<MenuItem onClick={bulkEditClicked}>
|
||||
<ListItemIcon><Icon>edit</Icon></ListItemIcon>
|
||||
Bulk Edit
|
||||
</MenuItem>
|
||||
}
|
||||
{
|
||||
table.capabilities.has(Capability.TABLE_DELETE) &&
|
||||
table.capabilities.has(Capability.TABLE_DELETE) && table.deletePermission &&
|
||||
<MenuItem onClick={bulkDeleteClicked}>
|
||||
<ListItemIcon><Icon>delete</Icon></ListItemIcon>
|
||||
Bulk Delete
|
||||
</MenuItem>
|
||||
}
|
||||
{(table.capabilities.has(Capability.TABLE_INSERT) || table.capabilities.has(Capability.TABLE_UPDATE) || table.capabilities.has(Capability.TABLE_DELETE)) && tableProcesses.length > 0 && <Divider />}
|
||||
{((table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission) || (table.capabilities.has(Capability.TABLE_UPDATE) && table.editPermission) || (table.capabilities.has(Capability.TABLE_DELETE) && table.deletePermission)) && tableProcesses.length > 0 && <Divider />}
|
||||
{tableProcesses.map((process) => (
|
||||
<MenuItem key={process.name} onClick={() => processClicked(process)}>
|
||||
<ListItemIcon><Icon>{process.iconName ?? "arrow_forward"}</Icon></ListItemIcon>
|
||||
@ -1072,7 +1072,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
||||
</MenuItem>
|
||||
))}
|
||||
{
|
||||
tableProcesses.length == 0 && !table.capabilities.has(Capability.TABLE_INSERT) && !table.capabilities.has(Capability.TABLE_UPDATE) && !table.capabilities.has(Capability.TABLE_DELETE) &&
|
||||
tableProcesses.length == 0 && !(table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission) && !(table.capabilities.has(Capability.TABLE_UPDATE) && table.editPermission) && !(table.capabilities.has(Capability.TABLE_DELETE) && table.deletePermission) &&
|
||||
<MenuItem disabled>
|
||||
<ListItemIcon><Icon>block</Icon></ListItemIcon>
|
||||
<i>No actions available</i>
|
||||
@ -1112,6 +1112,17 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
||||
document.scrollingElement.scrollTop = 0;
|
||||
}, [ pageNumber, rowsPerPage ]);
|
||||
|
||||
if(tableMetaData && !tableMetaData.readPermission)
|
||||
{
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<NavBar />
|
||||
<Alert severity="error">
|
||||
You do not have permission to view {tableMetaData?.label} records
|
||||
</Alert>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
@ -1148,7 +1159,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
||||
</Box>
|
||||
|
||||
{
|
||||
table.capabilities.has(Capability.TABLE_INSERT) &&
|
||||
table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission &&
|
||||
<QCreateNewButton />
|
||||
}
|
||||
|
||||
|
@ -95,6 +95,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element
|
||||
const [allTableProcesses, setAllTableProcesses] = useState([] as QProcessMetaData[]);
|
||||
const [actionsMenu, setActionsMenu] = useState(null);
|
||||
const [notFoundMessage, setNotFoundMessage] = useState(null);
|
||||
const [successMessage, setSuccessMessage] = useState(null as string);
|
||||
const [searchParams] = useSearchParams();
|
||||
const {setPageHeader} = useContext(QContext);
|
||||
const [activeModalProcess, setActiveModalProcess] = useState(null as QProcessMetaData);
|
||||
@ -108,6 +109,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element
|
||||
|
||||
const reload = () =>
|
||||
{
|
||||
setSuccessMessage(null);
|
||||
setNotFoundMessage(null);
|
||||
setAsyncLoadInited(false);
|
||||
setTableMetaData(null);
|
||||
@ -267,21 +269,30 @@ function RecordView({table, launchProcess}: Props): JSX.Element
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
const historyPurge = (path: string) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
HistoryUtils.ensurePathNotInHistory(location.pathname);
|
||||
}
|
||||
catch(e)
|
||||
{
|
||||
console.error("Error pushing history: " + e);
|
||||
}
|
||||
}
|
||||
|
||||
if (e instanceof QException)
|
||||
{
|
||||
if ((e as QException).status === "404")
|
||||
{
|
||||
setNotFoundMessage(`${tableMetaData.label} ${id} could not be found.`);
|
||||
|
||||
try
|
||||
{
|
||||
HistoryUtils.ensurePathNotInHistory(location.pathname);
|
||||
}
|
||||
catch(e)
|
||||
{
|
||||
console.error("Error pushing history: " + e);
|
||||
}
|
||||
|
||||
historyPurge(location.pathname)
|
||||
return;
|
||||
}
|
||||
else if ((e as QException).status === "403")
|
||||
{
|
||||
setNotFoundMessage(`You do not have permission to view ${tableMetaData.label} records`);
|
||||
historyPurge(location.pathname)
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -298,7 +309,6 @@ function RecordView({table, launchProcess}: Props): JSX.Element
|
||||
console.error("Error pushing history: " + e);
|
||||
}
|
||||
|
||||
|
||||
/////////////////////////////////////////////////
|
||||
// define the sections, e.g., for the left-bar //
|
||||
/////////////////////////////////////////////////
|
||||
@ -344,10 +354,10 @@ function RecordView({table, launchProcess}: Props): JSX.Element
|
||||
{
|
||||
section.fieldNames.map((fieldName: string) => (
|
||||
<Box key={fieldName} flexDirection="row" pr={2}>
|
||||
<Typography variant="button" textTransform="none" fontWeight="bold" pr={1}>
|
||||
<Typography variant="button" textTransform="none" fontWeight="bold" pr={1} color="rgb(52, 71, 103)">
|
||||
{tableMetaData.fields.get(fieldName).label}:
|
||||
</Typography>
|
||||
<Typography variant="button" textTransform="none" fontWeight="regular" color="text">
|
||||
<Typography variant="button" textTransform="none" fontWeight="regular" color="rgb(123, 128, 154)">
|
||||
{ValueUtils.getDisplayValue(tableMetaData.fields.get(fieldName), record, "view")}
|
||||
</Typography>
|
||||
</Box>
|
||||
@ -396,6 +406,11 @@ function RecordView({table, launchProcess}: Props): JSX.Element
|
||||
setSectionFieldElements(sectionFieldElements);
|
||||
setNonT1TableSections(nonT1TableSections);
|
||||
|
||||
if (searchParams.get("createSuccess") || searchParams.get("updateSuccess"))
|
||||
{
|
||||
setSuccessMessage(`${tableMetaData.label} successfully ${searchParams.get("createSuccess") ? "created" : "updated"}`);
|
||||
}
|
||||
|
||||
forceUpdate();
|
||||
})();
|
||||
}
|
||||
@ -445,14 +460,14 @@ function RecordView({table, launchProcess}: Props): JSX.Element
|
||||
keepMounted
|
||||
>
|
||||
{
|
||||
table.capabilities.has(Capability.TABLE_UPDATE) &&
|
||||
table.capabilities.has(Capability.TABLE_UPDATE) && table.editPermission &&
|
||||
<MenuItem onClick={() => navigate("edit")}>
|
||||
<ListItemIcon><Icon>edit</Icon></ListItemIcon>
|
||||
Edit
|
||||
</MenuItem>
|
||||
}
|
||||
{
|
||||
table.capabilities.has(Capability.TABLE_DELETE) &&
|
||||
table.capabilities.has(Capability.TABLE_DELETE) && table.deletePermission &&
|
||||
<MenuItem onClick={() =>
|
||||
{
|
||||
setActionsMenu(null);
|
||||
@ -463,7 +478,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element
|
||||
Delete
|
||||
</MenuItem>
|
||||
}
|
||||
{tableProcesses.length > 0 && (table.capabilities.has(Capability.TABLE_UPDATE) || table.capabilities.has(Capability.TABLE_DELETE)) && <Divider />}
|
||||
{tableProcesses.length > 0 && ((table.capabilities.has(Capability.TABLE_UPDATE) && table.editPermission) || (table.capabilities.has(Capability.TABLE_DELETE) && table.deletePermission)) && <Divider />}
|
||||
{tableProcesses.map((process) => (
|
||||
<MenuItem key={process.name} onClick={() => processClicked(process)}>
|
||||
<ListItemIcon><Icon>{process.iconName ?? "arrow_forward"}</Icon></ListItemIcon>
|
||||
@ -552,21 +567,18 @@ function RecordView({table, launchProcess}: Props): JSX.Element
|
||||
{
|
||||
notFoundMessage
|
||||
?
|
||||
<Box>{notFoundMessage}</Box>
|
||||
<Alert color="error" sx={{mb: 3}}>{notFoundMessage}</Alert>
|
||||
:
|
||||
<Box pb={3}>
|
||||
{
|
||||
(searchParams.get("createSuccess") || searchParams.get("updateSuccess")) ? (
|
||||
<Alert color="success" onClose={() =>
|
||||
{}}>
|
||||
{tableMetaData?.label}
|
||||
{" "}
|
||||
successfully
|
||||
{" "}
|
||||
{searchParams.get("createSuccess") ? "created" : "updated"}
|
||||
|
||||
successMessage ?
|
||||
<Alert color="success" sx={{mb: 3}} onClose={() =>
|
||||
{
|
||||
setSuccessMessage(null)
|
||||
}}>
|
||||
{successMessage}
|
||||
</Alert>
|
||||
) : ("")
|
||||
: ("")
|
||||
}
|
||||
|
||||
<Grid container spacing={3}>
|
||||
@ -588,7 +600,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element
|
||||
</Box>
|
||||
<Box display="flex" justifyContent="space-between" width="100%" alignItems="center">
|
||||
<Typography variant="h5">
|
||||
{tableMetaData && record ? `Viewing ${tableMetaData?.label}: ${record?.recordLabel}` : ""}
|
||||
{tableMetaData && record ? `Viewing ${tableMetaData?.label}: ${record?.recordLabel || ""}` : ""}
|
||||
</Typography>
|
||||
<QActionsMenuButton isOpen={actionsMenu} onClickHandler={openActionsMenu} />
|
||||
{renderActionsMenu}
|
||||
@ -610,10 +622,10 @@ function RecordView({table, launchProcess}: Props): JSX.Element
|
||||
<Box component="form" p={3}>
|
||||
<Grid container justifyContent="flex-end" spacing={3}>
|
||||
{
|
||||
table.capabilities.has(Capability.TABLE_DELETE) && <QDeleteButton onClickHandler={handleClickDeleteButton} />
|
||||
table.capabilities.has(Capability.TABLE_DELETE) && table.deletePermission && <QDeleteButton onClickHandler={handleClickDeleteButton} />
|
||||
}
|
||||
{
|
||||
table.capabilities.has(Capability.TABLE_UPDATE) && <QEditButton />
|
||||
table.capabilities.has(Capability.TABLE_UPDATE) && table.editPermission && <QEditButton />
|
||||
}
|
||||
</Grid>
|
||||
</Box>
|
||||
|
Reference in New Issue
Block a user