-
+
Temporary Travel Policy
@@ -284,7 +298,9 @@ export const CippWizardVacationConfirmation = (props) => {
? values.travelCountries.map((c) => c.label || c.value).join(', ')
: 'Not set'}
-
+
The policy and named location are deleted at the end date
-
-
-
-
-
-
- Type
-
- v && set("passwordType", v)}
- size="small"
- color="primary"
- >
- Classic
- Passphrase
-
- }
- >
- {passwordSave.isPending ? "Saving..." : "Save"}
-
-
-
-
- {isClassic
- ? "Random characters from the selected classes. Good for systems requiring specific character types. 16+ characters recommended for strong security."
- : "Random dictionary words joined together. Easier to remember and typically stronger at equal length. 5+ words recommended for high security."}
-
-
-
- {isClassic ? (
- <>
-
- {
- const value = e.target.value;
- if (value === '' || /^\d+$/.test(value)) {
- set("charCount", value);
- }
- }}
- size="small"
- sx={{ width: 120 }}
- inputProps={{
- style: { height: "40px" },
- min: 8,
- max: 256
- }}
- error={config.charCount === ''}
- helperText={config.charCount === '' ? "Length cannot be empty" : ""}
- />
-
-
-
- set("includeUppercase", e.target.checked)} />}
- label={Uppercase (A-Z)}
- />
-
-
- set("includeLowercase", e.target.checked)} />}
- label={Lowercase (a-z)}
- />
-
-
- set("includeDigits", e.target.checked)} />}
- label={Digits (0-9)}
- />
-
-
- set("includeSpecialChars", e.target.checked)} />}
- label={Special Characters}
- />
-
-
- {config.includeSpecialChars && (
- set("specialCharSet", e.target.value)}
- size="small"
- fullWidth
- helperText="Allowed: !@#$%^&*()-_=+/"
- />
- )}
- >
- ) : (
- <>
-
- {
- const value = e.target.value;
- if (value === '' || /^\d+$/.test(value)) {
- set("wordCount", value);
- }
- }}
- size="small"
- sx={{ width: 120, maxWidth: 160 }}
- inputProps={{
- style: { height: "40px" },
- min: 2,
- max: 10
- }}
- error={config.wordCount === ''}
- helperText={config.wordCount === '' ? "Word count cannot be empty" : ""}
- />
- set("separator", e.target.value)}
- size="small"
- sx={{ maxWidth: 120 }}
- />
-
- Allowed: single space, empty, or !@#$%^&*()-_=+/
-
-
-
-
- set("capitalizeWords", e.target.checked)} />}
- label={Capitalize words}
- />
-
-
- set("appendNumber", e.target.checked)} />}
- label={Append number}
- />
-
-
- set("appendSpecialChar", e.target.checked)} />}
- label={Append Special Character}
- />
-
-
- {config.appendSpecialChar && (
- set("specialCharSet", e.target.value)}
- size="small"
- fullWidth
- helperText="Allowed: !@#$%^&*()-_=+/"
- />
- )}
- >
- )}
-
-
-
-
-
-
-
- >
- );
-};
-
-Page.getLayout = (page) => {page};
-
-export default Page;
diff --git a/src/pages/cipp/settings/password-config/index.jsx b/src/pages/cipp/settings/password-config/index.jsx
new file mode 100644
index 000000000000..e27206874aaa
--- /dev/null
+++ b/src/pages/cipp/settings/password-config/index.jsx
@@ -0,0 +1,359 @@
+import { useEffect, useState, useCallback } from "react";
+import { CippIcons } from "../../../../utils/icon-registry";
+import {
+ Alert,
+ Box,
+ Button,
+ Card,
+ CardContent,
+ Container,
+ Divider,
+ FormControlLabel,
+ Stack,
+ SvgIcon,
+ Switch,
+ TextField,
+ ToggleButton,
+ ToggleButtonGroup,
+ Typography,
+} from "@mui/material";
+import { Grid } from "@mui/system";
+import { Layout as DashboardLayout } from "../../../../layouts/index";
+import { useRouter } from "next/router";
+import { ApiGetCall, ApiPostCall } from "../../../../api/ApiCall";
+import { CippHead } from "../../../../components/CippComponents/CippHead";
+import { CippApiResults } from "../../../../components/CippComponents/CippApiResults";
+
+// Password configuration constants
+const PASSWORD_TYPES = {
+ CLASSIC: 'Classic',
+ PASSPHRASE: 'Passphrase'
+};
+
+const DEFAULT_VALUES = {
+ CHAR_COUNT: 14,
+ WORD_COUNT: 4,
+ SPECIAL_CHAR_SET: '$%&*#',
+ SEPARATOR: '-'
+};
+
+function normalizeConfigForBackend(config) {
+ return {
+ passwordType: String(config.passwordType || PASSWORD_TYPES.CLASSIC),
+ charCount: String(parseInt(config.charCount, 10) || DEFAULT_VALUES.CHAR_COUNT),
+ includeUppercase: Boolean(config.includeUppercase),
+ includeLowercase: Boolean(config.includeLowercase),
+ includeDigits: Boolean(config.includeDigits),
+ includeSpecialChars: Boolean(config.includeSpecialChars),
+ specialCharSet: String(config.specialCharSet || DEFAULT_VALUES.SPECIAL_CHAR_SET),
+ wordCount: String(parseInt(config.wordCount, 10) || DEFAULT_VALUES.WORD_COUNT),
+ separator: config.separator !== undefined && config.separator !== null ? String(config.separator) : DEFAULT_VALUES.SEPARATOR,
+ capitalizeWords: Boolean(config.capitalizeWords),
+ appendNumber: Boolean(config.appendNumber),
+ appendSpecialChar: Boolean(config.appendSpecialChar),
+ };
+}
+
+const DEFAULT_CONFIG = {
+ passwordType: PASSWORD_TYPES.CLASSIC,
+ charCount: String(DEFAULT_VALUES.CHAR_COUNT),
+ includeUppercase: true,
+ includeLowercase: true,
+ includeDigits: true,
+ includeSpecialChars: true,
+ specialCharSet: DEFAULT_VALUES.SPECIAL_CHAR_SET,
+ wordCount: String(DEFAULT_VALUES.WORD_COUNT),
+ separator: DEFAULT_VALUES.SEPARATOR,
+ capitalizeWords: false,
+ appendNumber: false,
+ appendSpecialChar: false,
+};
+
+// ── Page ──────────────────────────────────────────────────────────────────────
+
+const Page = () => {
+ const router = useRouter();
+ const [config, setConfig] = useState(DEFAULT_CONFIG);
+
+ const passwordSetting = ApiGetCall({ url: "/api/ExecPasswordConfig?list=true", queryKey: "PasswordSettings" });
+ const passwordSave = ApiPostCall({ datafromUrl: true, relatedQueryKeys: "PasswordSettings" });
+
+ useEffect(() => {
+ if (passwordSetting.isSuccess && passwordSetting.data) {
+ const r = passwordSetting.data.Results;
+ const toBool = (v, def) => {
+ if (v === undefined || v === null) return def;
+ if (typeof v === 'boolean') return v;
+ if (typeof v === 'string') return v.toLowerCase() === 'true';
+ if (typeof v === 'number') return v === 1;
+ return def;
+ };
+
+ setConfig({
+ passwordType: r.passwordType || DEFAULT_CONFIG.passwordType,
+ charCount: String(parseInt(r.charCount, 10) || DEFAULT_CONFIG.charCount),
+ includeUppercase: toBool(r.includeUppercase, DEFAULT_CONFIG.includeUppercase),
+ includeLowercase: toBool(r.includeLowercase, DEFAULT_CONFIG.includeLowercase),
+ includeDigits: toBool(r.includeDigits, DEFAULT_CONFIG.includeDigits),
+ includeSpecialChars: toBool(r.includeSpecialChars, DEFAULT_CONFIG.includeSpecialChars),
+ specialCharSet: r.specialCharSet || DEFAULT_CONFIG.specialCharSet,
+ wordCount: String(parseInt(r.wordCount, 10) || DEFAULT_CONFIG.wordCount),
+ separator: r.separator !== undefined ? r.separator : DEFAULT_CONFIG.separator,
+ capitalizeWords: toBool(r.capitalizeWords, DEFAULT_CONFIG.capitalizeWords),
+ appendNumber: toBool(r.appendNumber, DEFAULT_CONFIG.appendNumber),
+ appendSpecialChar: toBool(r.appendSpecialChar, DEFAULT_CONFIG.appendSpecialChar),
+ });
+ }
+ }, [passwordSetting.isSuccess, passwordSetting.data]);
+
+ const set = useCallback((field, value) => {
+ setConfig((p) => ({ ...p, [field]: value }));
+ }, []);
+
+ const isClassic = config.passwordType === PASSWORD_TYPES.CLASSIC;
+
+ const handleSave = () => {
+ const normalizedConfig = normalizeConfigForBackend(config);
+
+ passwordSave.mutate(
+ {
+ url: "/api/ExecPasswordConfig",
+ data: normalizedConfig,
+ queryKey: "PasswordSettingsPost",
+ }
+ );
+ };
+
+ const handleBackToSettings = () => {
+ router.push("/cipp/settings");
+ };
+
+ return (
+ <>
+
+
+
+
+
+ {/* wraps on narrow widths instead of clipping the title */}
+
+ Password Configuration
+ }
+ onClick={handleBackToSettings}
+ >
+ Settings
+
+
+
+
+
+
+
+
+ Type
+
+ v && set("passwordType", v)}
+ size="small"
+ color="primary"
+ >
+ Classic
+ Passphrase
+
+ }
+ >
+ {passwordSave.isPending ? "Saving..." : "Save"}
+
+
+
+
+ {isClassic
+ ? "Random characters from the selected classes. Good for systems requiring specific character types. 16+ characters recommended for strong security."
+ : "Random dictionary words joined together. Easier to remember and typically stronger at equal length. 5+ words recommended for high security."}
+
+
+
+ {isClassic ? (
+ <>
+
+ {
+ const value = e.target.value;
+ if (value === '' || /^\d+$/.test(value)) {
+ set("charCount", value);
+ }
+ }}
+ size="small"
+ sx={{ width: 120 }}
+ error={config.charCount === ''}
+ helperText={config.charCount === '' ? "Length cannot be empty" : ""}
+ slotProps={{
+ htmlInput: {
+ style: { height: "40px" },
+ min: 8,
+ max: 256
+ }
+ }}
+ />
+
+
+
+ set("includeUppercase", e.target.checked)} />}
+ label={Uppercase (A-Z)}
+ />
+
+
+ set("includeLowercase", e.target.checked)} />}
+ label={Lowercase (a-z)}
+ />
+
+
+ set("includeDigits", e.target.checked)} />}
+ label={Digits (0-9)}
+ />
+
+
+ set("includeSpecialChars", e.target.checked)} />}
+ label={Special Characters}
+ />
+
+
+ {config.includeSpecialChars && (
+ set("specialCharSet", e.target.value)}
+ size="small"
+ fullWidth
+ helperText="Allowed: !@#$%^&*()-_=+/"
+ />
+ )}
+ >
+ ) : (
+ <>
+ {/* full width fields stacked on xs; helper text moves below instead of squeezing inline */}
+
+
+ {
+ const value = e.target.value;
+ if (value === '' || /^\d+$/.test(value)) {
+ set("wordCount", value);
+ }
+ }}
+ size="small"
+ sx={{ width: { xs: "100%", sm: 120 }, maxWidth: { xs: "100%", sm: 160 } }}
+ error={config.wordCount === ''}
+ helperText={config.wordCount === '' ? "Word count cannot be empty" : ""}
+ slotProps={{
+ htmlInput: {
+ style: { height: "40px" },
+ min: 2,
+ max: 10
+ }
+ }}
+ />
+ set("separator", e.target.value)}
+ size="small"
+ sx={{ width: { xs: "100%", sm: "auto" }, maxWidth: { xs: "100%", sm: 120 } }}
+ />
+
+
+ Allowed: single space, empty, or !@#$%^&*()-_=+/
+
+
+
+
+ set("capitalizeWords", e.target.checked)} />}
+ label={Capitalize words}
+ />
+
+
+ set("appendNumber", e.target.checked)} />}
+ label={Append number}
+ />
+
+
+ set("appendSpecialChar", e.target.checked)} />}
+ label={Append Special Character}
+ />
+
+
+ {config.appendSpecialChar && (
+ set("specialCharSet", e.target.value)}
+ size="small"
+ fullWidth
+ helperText="Allowed: !@#$%^&*()-_=+/"
+ />
+ )}
+ >
+ )}
+
+
+
+
+
+
+
+ >
+ );
+};
+
+Page.getLayout = (page) => {page};
+
+export default Page;
diff --git a/src/pages/cipp/settings/permissions.js b/src/pages/cipp/settings/permissions.js
deleted file mode 100644
index 214fd478b1cb..000000000000
--- a/src/pages/cipp/settings/permissions.js
+++ /dev/null
@@ -1,65 +0,0 @@
-import { Alert, Container } from "@mui/material";
-import { Grid } from "@mui/system";
-import { TabbedLayout } from "../../../layouts/TabbedLayout";
-import { Layout as DashboardLayout } from "../../../layouts/index.js";
-import tabOptions from "./tabOptions";
-import CippPermissionCheck from "../../../components/CippSettings/CippPermissionCheck";
-import { CippPermissionReport } from "../../../components/CippSettings/CippPermissionReport";
-import { useState } from "react";
-import { ApiGetCall } from "../../../api/ApiCall";
-
-const Page = () => {
- const [importReport, setImportReport] = useState(false);
-
- // Same signal the Add Tenant wizard uses to decide whether partner-only flows apply, and the
- // same shared query key so the two pages hit one cache entry.
- const organization = ApiGetCall({
- url: "/api/ListPartnerTenantInfo",
- queryKey: "ListPartnerTenantInfo",
- });
-
- const partnerCheckComplete = organization.isSuccess || organization.isError;
- const isPartner = organization.isSuccess && Boolean(organization.data?.isPartnerTenant);
-
- // Keep the GDAP check visible until we know it does not apply, and always show it when an
- // imported report contains GDAP data.
- const showGdapCheck = !partnerCheckComplete || isPartner || Boolean(importReport?.GDAP);
-
- return (
-
-
- {/* Below lg the report renders as a fixed FAB (plus a portaled dialog), so this item
- is empty in flow — display: contents dissolves it, or grid spacing leaves a blank
- 16px row between the picker and the first card. */}
-
-
-
-
-
-
-
- {showGdapCheck ? (
-
- ) : (
-
- GDAP checks do not apply to this environment. Your tenants are added directly rather
- than through Microsoft Partner Center relationships, so access is verified per tenant
- in the Tenants check below.
-
- )}
-
-
-
-
-
-
- );
-};
-
-Page.getLayout = (page) => (
-
- {page}
-
-);
-
-export default Page;
diff --git a/src/pages/cipp/settings/permissions.jsx b/src/pages/cipp/settings/permissions.jsx
new file mode 100644
index 000000000000..29ecd53284e2
--- /dev/null
+++ b/src/pages/cipp/settings/permissions.jsx
@@ -0,0 +1,65 @@
+import { Alert, Container } from "@mui/material";
+import { Grid } from "@mui/system";
+import { TabbedLayout } from "../../../layouts/TabbedLayout";
+import { Layout as DashboardLayout } from "../../../layouts/index";
+import tabOptions from "./tabOptions";
+import CippPermissionCheck from "../../../components/CippSettings/CippPermissionCheck";
+import { CippPermissionReport } from "../../../components/CippSettings/CippPermissionReport";
+import { useState } from "react";
+import { ApiGetCall } from "../../../api/ApiCall";
+
+const Page = () => {
+ const [importReport, setImportReport] = useState(false);
+
+ // Same signal the Add Tenant wizard uses to decide whether partner-only flows apply, and the
+ // same shared query key so the two pages hit one cache entry.
+ const organization = ApiGetCall({
+ url: "/api/ListPartnerTenantInfo",
+ queryKey: "ListPartnerTenantInfo",
+ });
+
+ const partnerCheckComplete = organization.isSuccess || organization.isError;
+ const isPartner = organization.isSuccess && Boolean(organization.data?.isPartnerTenant);
+
+ // Keep the GDAP check visible until we know it does not apply, and always show it when an
+ // imported report contains GDAP data.
+ const showGdapCheck = !partnerCheckComplete || isPartner || Boolean(importReport?.GDAP);
+
+ return (
+
+
+ {/* Below lg the report renders as a fixed FAB (plus a portaled dialog), so this item
+ is empty in flow — display: contents dissolves it, or grid spacing leaves a blank
+ 16px row between the picker and the first card. */}
+
+
+
+
+
+
+
+ {showGdapCheck ? (
+
+ ) : (
+
+ GDAP checks do not apply to this environment. Your tenants are added directly rather
+ than through Microsoft Partner Center relationships, so access is verified per tenant
+ in the Tenants check below.
+
+ )}
+
+
+
+
+
+
+ );
+};
+
+Page.getLayout = (page) => (
+
+ {page}
+
+);
+
+export default Page;
diff --git a/src/pages/cipp/settings/siem.js b/src/pages/cipp/settings/siem.js
deleted file mode 100644
index 2f201199dbcf..000000000000
--- a/src/pages/cipp/settings/siem.js
+++ /dev/null
@@ -1,192 +0,0 @@
-import {
- Alert,
- Card,
- CardContent,
- CardHeader,
- Container,
- Divider,
- Link as MuiLink,
- Typography,
-} from "@mui/material";
-import { Grid } from "@mui/system";
-import { Layout as DashboardLayout } from "../../../layouts/index.js";
-import { TabbedLayout } from "../../../layouts/TabbedLayout";
-import tabOptions from "./tabOptions";
-import CippSiemSettings from "../../../components/CippSettings/CippSiemSettings";
-import { CippCopyToClipBoard } from "../../../components/CippComponents/CippCopyToClipboard";
-
-const filterExamples = [
- {
- label: "Specific day",
- filter: "PartitionKey eq 'YYYYMMDD'",
- note: "Replace YYYYMMDD with the current date, e.g. 20260312",
- },
- {
- label: "Date range (last 7 days)",
- filter: "PartitionKey ge '20260305' and PartitionKey le '20260312'",
- note: "Use ge/le to query a range of dates",
- },
-];
-
-const Page = () => {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
- How Logs are Stored
-
-
- CIPP writes all log entries to an Azure Table Storage table called{" "}
- CippLogs. Each row is partitioned by date using the format{" "}
- YYYYMMDD as the PartitionKey, with a unique GUID as the{" "}
- RowKey.
-
-
-
-
-
- Always include a PartitionKey filter in your queries. Azure Table
- Storage performs a full table scan without one, which is slow and expensive on
- large tables. Use eq for a single day or ge /{" "}
- le for a date range.{" "}
- The date partition is in UTC time, so you may need to use a date
- range to account for timezone differences.
-
-
-
-
-
- Example $filter Queries
-
-
- Append &$filter= to your SAS URL to filter results. Use{" "}
- eq, ne, gt, lt,{" "}
- ge, le, and combine with and /{" "}
- or.
-
- {filterExamples.map((ex) => (
-
-
- {ex.label}
-
-
- $filter={ex.filter}
-
-
- {ex.note && (
-
- {ex.note}
-
- )}
-
- ))}
-
-
-
-
-
-
- Azure Tables Documentation
-
-
-
-
-
- Querying Tables and Entities
-
- {" — "}filter syntax, operators, and supported data types
-
-
-
- Query Timeout and Pagination
-
- {" — "}handling continuation tokens for large result sets
-
-
-
-
-
-
-
-
-
- );
-};
-
-Page.getLayout = (page) => (
-
- {page}
-
-);
-
-export default Page;
diff --git a/src/pages/cipp/settings/siem.jsx b/src/pages/cipp/settings/siem.jsx
new file mode 100644
index 000000000000..12a281dfc40b
--- /dev/null
+++ b/src/pages/cipp/settings/siem.jsx
@@ -0,0 +1,207 @@
+import {
+ Alert,
+ Card,
+ CardContent,
+ CardHeader,
+ Container,
+ Divider,
+ Link as MuiLink,
+ Typography,
+} from "@mui/material";
+import { Grid } from "@mui/system";
+import { Layout as DashboardLayout } from "../../../layouts/index";
+import { TabbedLayout } from "../../../layouts/TabbedLayout";
+import tabOptions from "./tabOptions";
+import CippSiemSettings from "../../../components/CippSettings/CippSiemSettings";
+import { CippCopyToClipBoard } from "../../../components/CippComponents/CippCopyToClipboard";
+
+const filterExamples = [
+ {
+ label: "Specific day",
+ filter: "PartitionKey eq 'YYYYMMDD'",
+ note: "Replace YYYYMMDD with the current date, e.g. 20260312",
+ },
+ {
+ label: "Date range (last 7 days)",
+ filter: "PartitionKey ge '20260305' and PartitionKey le '20260312'",
+ note: "Use ge/le to query a range of dates",
+ },
+];
+
+const Page = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ How Logs are Stored
+
+
+ CIPP writes all log entries to an Azure Table Storage table called{" "}
+ CippLogs. Each row is partitioned by date using the format{" "}
+ YYYYMMDD as the PartitionKey, with a unique GUID as the{" "}
+ RowKey.
+
+
+
+
+
+ Always include a PartitionKey filter in your queries. Azure Table
+ Storage performs a full table scan without one, which is slow and expensive on
+ large tables. Use eq for a single day or ge /{" "}
+ le for a date range.{" "}
+ The date partition is in UTC time, so you may need to use a date
+ range to account for timezone differences.
+
+
+
+
+
+ Example $filter Queries
+
+
+ Append &$filter= to your SAS URL to filter results. Use{" "}
+ eq, ne, gt, lt,{" "}
+ ge, le, and combine with and /{" "}
+ or.
+
+ {filterExamples.map((ex) => (
+
+
+ {ex.label}
+
+
+ $filter={ex.filter}
+
+
+ {ex.note && (
+
+ {ex.note}
+
+ )}
+
+ ))}
+
+
+
+
+
+
+ Azure Tables Documentation
+
+
+
+
+
+ Querying Tables and Entities
+
+ {" — "}filter syntax, operators, and supported data types
+
+
+
+ Query Timeout and Pagination
+
+ {" — "}handling continuation tokens for large result sets
+
- Intune rejects a multi-admin approval decision from a GDAP
- delegated identity and from an application identity alike, so
- neither CIPP nor any other partner tooling can approve or reject
- these requests.
-
-
- The decision has to come from an account signed in to the customer
- tenant that is a member of the approver group on the access
- policy, and it cannot be the account that raised the request.
- Approve or reject under{' '}
-
- Tenant administration > Multi Admin Approval > Received
- requests
-
- .
-
-
- A JIT admin account may be able to fill this role, provided it is
- added to the approver security group and that group is directly
- assigned to an Intune RBAC role. This is untested.
-
-
-
-
- Cancel
- }
- onClick={handoffDialog.handleClose}
- >
- Open Intune
-
-
-
- >
- )
-}
-
-Page.getLayout = (page) => {page}
-
-export default Page
diff --git a/src/pages/endpoint/MEM/approval-requests/index.jsx b/src/pages/endpoint/MEM/approval-requests/index.jsx
new file mode 100644
index 000000000000..450fa079edfd
--- /dev/null
+++ b/src/pages/endpoint/MEM/approval-requests/index.jsx
@@ -0,0 +1,135 @@
+import { CippIcons } from '../../../../utils/icon-registry'
+import {
+ Alert,
+ Button,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogContentText,
+ DialogTitle,
+} from '@mui/material'
+import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx'
+import { Layout as DashboardLayout } from '../../../../layouts/index'
+import { useSettings } from '../../../../hooks/use-settings'
+import { useDialog } from '../../../../hooks/use-dialog'
+
+const Page = () => {
+ const pageTitle = 'MAA Requests'
+ const tenantFilter = useSettings().currentTenant
+ const handoffDialog = useDialog()
+
+ // This page deliberately has no row actions. Intune answers approve and reject with "Valid user
+ // identity required while Multi Admin Approval is enabled" for the delegated partner identity and
+ // for the application identity alike, and a requestor may never approve its own request - which
+ // CIPP always is. Delete is not offered either: the request is the tenant's record of a pending
+ // decision, and removing it neither applies nor cancels the change behind it. The decision has to
+ // be made by an account inside the tenant, so all this page offers is a handoff to Intune.
+ const offCanvas = {
+ extendedInfoFields: [
+ 'id',
+ 'status',
+ 'operation',
+ 'target',
+ 'operationTypes',
+ 'requestJustification',
+ 'approvalJustification',
+ 'requestedBy',
+ 'approvedBy',
+ 'requestDateTime',
+ 'expirationDateTime',
+ 'lastModifiedDateTime',
+ ],
+ }
+
+ // requestedBy and approvedBy are kept out of the table: Intune leaves the requestor and approver
+ // identity sets null, so as columns they would be dead space on every row.
+ const simpleColumns = [
+ 'status',
+ 'operation',
+ 'target',
+ 'operationTypes',
+ 'requestJustification',
+ 'requestDateTime',
+ 'expirationDateTime',
+ ]
+
+ return (
+ <>
+ }
+ onClick={() => handoffDialog.handleOpen()}
+ >
+ Action in Intune
+
+ }
+ tableFilter={
+
+ Multi-admin approval holds these changes until a second
+ administrator approves them, and the decision cannot be made from
+ CIPP. Once a request is approved, anything CIPP raised is reapplied
+ automatically. Requests expire after 3 days.
+
+ }
+ apiUrl="/api/ListIntuneApprovalRequests"
+ queryKey="ListIntuneApprovalRequests"
+ offCanvas={offCanvas}
+ simpleColumns={simpleColumns}
+ />
+
+ Approvals cannot be made through CIPP
+
+
+
+ Intune rejects a multi-admin approval decision from a GDAP
+ delegated identity and from an application identity alike, so
+ neither CIPP nor any other partner tooling can approve or reject
+ these requests.
+
+
+ The decision has to come from an account signed in to the customer
+ tenant that is a member of the approver group on the access
+ policy, and it cannot be the account that raised the request.
+ Approve or reject under{' '}
+
+ Tenant administration > Multi Admin Approval > Received
+ requests
+
+ .
+
+
+ A JIT admin account may be able to fill this role, provided it is
+ added to the approver security group and that group is directly
+ assigned to an Intune RBAC role. This is untested.
+
- The GNU Affero General Public License is a free, copyleft license for software and other
- kinds of works, specifically designed to ensure cooperation with the community in the case
- of network server software.
-
-
-
- The licenses for most software and other practical works are designed to take away your
- freedom to share and change the works. By contrast, our General Public Licenses are intended
- to guarantee your freedom to share and change all versions of a program--to make sure it
- remains free software for all its users.
-
-
-
- When we speak of free software, we are referring to freedom, not price. Our General Public
- Licenses are designed to make sure that you have the freedom to distribute copies of free
- software (and charge for them if you wish), that you receive source code or can get it if
- you want it, that you can change the software or use pieces of it in new free programs, and
- that you know you can do these things.
-
-
-
- Developers that use our General Public Licenses protect your rights with two steps: (1)
- assert copyright on the software, and (2) offer you this License which gives you legal
- permission to copy, distribute and/or modify the software.
-
-
-
- A secondary benefit of defending all users' freedom is that improvements made in
- alternate versions of the program, if they receive widespread use, become available for
- other developers to incorporate. Many developers of free software are heartened and
- encouraged by the resulting cooperation. However, in the case of software used on network
- servers, this result may fail to come about. The GNU General Public License permits making a
- modified version and letting the public access it on a server without ever releasing its
- source code to the public.
-
-
-
- The GNU Affero General Public License is designed specifically to ensure that, in such
- cases, the modified source code becomes available to the community. It requires the operator
- of a network server to provide the source code of the modified version running there to the
- users of that server. Therefore, public use of a modified version, on a publicly accessible
- server, gives the public access to the source code of the modified version.
-
-
-
- An older license, called the Affero General Public License and published by Affero, was
- designed to accomplish similar goals. This is a different license, not a version of the
- Affero GPL, but Affero has released a new version of the Affero GPL which permits
- relicensing under this license.
-
-
-
The precise terms and conditions for copying, distribution and modification follow.
-
-
TERMS AND CONDITIONS
-
-
0. Definitions.
-
-
"This License" refers to version 3 of the GNU Affero General Public License.
-
-
- "Copyright" also means copyright-like laws that apply to other kinds of works,
- such as semiconductor masks.
-
-
-
- "The Program" refers to any copyrightable work licensed under this License. Each
- licensee is addressed as "you". "Licensees" and "recipients"
- may be individuals or organizations.
-
-
-
- To "modify" a work means to copy from or adapt all or part of the work in a
- fashion requiring copyright permission, other than the making of an exact copy. The
- resulting work is called a "modified version" of the earlier work or a work
- "based on" the earlier work.
-
-
-
- A "covered work" means either the unmodified Program or a work based on the
- Program.
-
-
-
- To "propagate" a work means to do anything with it that, without permission, would
- make you directly or secondarily liable for infringement under applicable copyright law,
- except executing it on a computer or modifying a private copy. Propagation includes copying,
- distribution (with or without modification), making available to the public, and in some
- countries other activities as well.
-
-
-
- To "convey" a work means any kind of propagation that enables other parties to
- make or receive copies. Mere interaction with a user through a computer network, with no
- transfer of a copy, is not conveying.
-
-
-
- An interactive user interface displays "Appropriate Legal Notices" to the extent
- that it includes a convenient and prominently visible feature that (1) displays an
- appropriate copyright notice, and (2) tells the user that there is no warranty for the work
- (except to the extent that warranties are provided), that licensees may convey the work
- under this License, and how to view a copy of this License. If the interface presents a list
- of user commands or options, such as a menu, a prominent item in the list meets this
- criterion.
-
-
-
1. Source Code.
-
-
- The "source code" for a work means the preferred form of the work for making
- modifications to it. "Object code" means any non-source form of a work.
-
-
-
- A "Standard Interface" means an interface that either is an official standard
- defined by a recognized standards body, or, in the case of interfaces specified for a
- particular programming language, one that is widely used among developers working in that
- language.
-
-
-
- The "System Libraries" of an executable work include anything, other than the work
- as a whole, that (a) is included in the normal form of packaging a Major Component, but
- which is not part of that Major Component, and (b) serves only to enable use of the work
- with that Major Component, or to implement a Standard Interface for which an implementation
- is available to the public in source code form. A "Major Component", in this
- context, means a major essential component (kernel, window system, and so on) of the
- specific operating system (if any) on which the executable work runs, or a compiler used to
- produce the work, or an object code interpreter used to run it.
-
-
-
- The "Corresponding Source" for a work in object code form means all the source
- code needed to generate, install, and (for an executable work) run the object code and to
- modify the work, including scripts to control those activities. However, it does not include
- the work's System Libraries, or general-purpose tools or generally available free
- programs which are used unmodified in performing those activities but which are not part of
- the work. For example, Corresponding Source includes interface definition files associated
- with source files for the work, and the source code for shared libraries and dynamically
- linked subprograms that the work is specifically designed to require, such as by intimate
- data communication or control flow between those subprograms and other parts of the work.
-
-
-
- The Corresponding Source need not include anything that users can regenerate automatically
- from other parts of the Corresponding Source.
-
-
-
The Corresponding Source for a work in source code form is that same work.
-
-
2. Basic Permissions.
-
-
- All rights granted under this License are granted for the term of copyright on the Program,
- and are irrevocable provided the stated conditions are met. This License explicitly affirms
- your unlimited permission to run the unmodified Program. The output from running a covered
- work is covered by this License only if the output, given its content, constitutes a covered
- work. This License acknowledges your rights of fair use or other equivalent, as provided by
- copyright law.
-
-
-
- You may make, run and propagate covered works that you do not convey, without conditions so
- long as your license otherwise remains in force. You may convey covered works to others for
- the sole purpose of having them make modifications exclusively for you, or provide you with
- facilities for running those works, provided that you comply with the terms of this License
- in conveying all material for which you do not control copyright. Those thus making or
- running the covered works for you must do so exclusively on your behalf, under your
- direction and control, on terms that prohibit them from making any copies of your
- copyrighted material outside their relationship with you.
-
-
-
- Conveying under any other circumstances is permitted solely under the conditions stated
- below. Sublicensing is not allowed; section 10 makes it unnecessary.
-
-
-
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
-
-
- No covered work shall be deemed part of an effective technological measure under any
- applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted
- on 20 December 1996, or similar laws prohibiting or restricting circumvention of such
- measures.
-
-
-
- When you convey a covered work, you waive any legal power to forbid circumvention of
- technological measures to the extent such circumvention is effected by exercising rights
- under this License with respect to the covered work, and you disclaim any intention to limit
- operation or modification of the work as a means of enforcing, against the work's
- users, your or third parties' legal rights to forbid circumvention of technological
- measures.
-
-
-
4. Conveying Verbatim Copies.
-
-
- You may convey verbatim copies of the Program's source code as you receive it, in any
- medium, provided that you conspicuously and appropriately publish on each copy an
- appropriate copyright notice; keep intact all notices stating that this License and any
- non-permissive terms added in accord with section 7 apply to the code; keep intact all
- notices of the absence of any warranty; and give all recipients a copy of this License along
- with the Program.
-
-
-
- You may charge any price or no price for each copy that you convey, and you may offer
- support or warranty protection for a fee.
-
-
-
5. Conveying Modified Source Versions.
-
-
- You may convey a work based on the Program, or the modifications to produce it from the
- Program, in the form of source code under the terms of section 4, provided that you also
- meet all of these conditions:
-
-
-
-
- a) The work must carry prominent notices stating that you modified it, and giving a
- relevant date.
-
-
-
- b) The work must carry prominent notices stating that it is released under this License
- and any conditions added under section 7. This requirement modifies the requirement in
- section 4 to "keep intact all notices".
-
-
-
- c) You must license the entire work, as a whole, under this License to anyone who comes
- into possession of a copy. This License will therefore apply, along with any applicable
- section 7 additional terms, to the whole of the work, and all its parts, regardless of how
- they are packaged. This License gives no permission to license the work in any other way,
- but it does not invalidate such permission if you have separately received it.
-
-
-
- d) If the work has interactive user interfaces, each must display Appropriate Legal
- Notices; however, if the Program has interactive interfaces that do not display
- Appropriate Legal Notices, your work need not make them do so.
-
-
-
-
- A compilation of a covered work with other separate and independent works, which are not by
- their nature extensions of the covered work, and which are not combined with it such as to
- form a larger program, in or on a volume of a storage or distribution medium, is called an
- "aggregate" if the compilation and its resulting copyright are not used to limit
- the access or legal rights of the compilation's users beyond what the individual works
- permit. Inclusion of a covered work in an aggregate does not cause this License to apply to
- the other parts of the aggregate.
-
-
-
6. Conveying Non-Source Forms.
-
-
- You may convey a covered work in object code form under the terms of sections 4 and 5,
- provided that you also convey the machine-readable Corresponding Source under the terms of
- this License, in one of these ways:
-
-
-
-
- a) Convey the object code in, or embodied in, a physical product (including a physical
- distribution medium), accompanied by the Corresponding Source fixed on a durable physical
- medium customarily used for software interchange.
-
-
-
- b) Convey the object code in, or embodied in, a physical product (including a physical
- distribution medium), accompanied by a written offer, valid for at least three years and
- valid for as long as you offer spare parts or customer support for that product model, to
- give anyone who possesses the object code either (1) a copy of the Corresponding Source
- for all the software in the product that is covered by this License, on a durable physical
- medium customarily used for software interchange, for a price no more than your reasonable
- cost of physically performing this conveying of source, or (2) access to copy the
- Corresponding Source from a network server at no charge.
-
-
-
- c) Convey individual copies of the object code with a copy of the written offer to provide
- the Corresponding Source. This alternative is allowed only occasionally and
- noncommercially, and only if you received the object code with such an offer, in accord
- with subsection 6b.
-
-
-
- d) Convey the object code by offering access from a designated place (gratis or for a
- charge), and offer equivalent access to the Corresponding Source in the same way through
- the same place at no further charge. You need not require recipients to copy the
- Corresponding Source along with the object code. If the place to copy the object code is a
- network server, the Corresponding Source may be on a different server (operated by you or
- a third party) that supports equivalent copying facilities, provided you maintain clear
- directions next to the object code saying where to find the Corresponding Source.
- Regardless of what server hosts the Corresponding Source, you remain obligated to ensure
- that it is available for as long as needed to satisfy these requirements.
-
-
-
- e) Convey the object code using peer-to-peer transmission, provided you inform other peers
- where the object code and Corresponding Source of the work are being offered to the
- general public at no charge under subsection 6d.
-
-
-
-
- A separable portion of the object code, whose source code is excluded from the Corresponding
- Source as a System Library, need not be included in conveying the object code work.
-
-
-
- A "User Product" is either (1) a "consumer product", which means any
- tangible personal property which is normally used for personal, family, or household
- purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining
- whether a product is a consumer product, doubtful cases shall be resolved in favor of
- coverage. For a particular product received by a particular user, "normally used"
- refers to a typical or common use of that class of product, regardless of the status of the
- particular user or of the way in which the particular user actually uses, or expects or is
- expected to use, the product. A product is a consumer product regardless of whether the
- product has substantial commercial, industrial or non-consumer uses, unless such uses
- represent the only significant mode of use of the product.
-
-
-
- "Installation Information" for a User Product means any methods, procedures,
- authorization keys, or other information required to install and execute modified versions
- of a covered work in that User Product from a modified version of its Corresponding Source.
- The information must suffice to ensure that the continued functioning of the modified object
- code is in no case prevented or interfered with solely because modification has been made.
-
-
-
- If you convey an object code work under this section in, or with, or specifically for use
- in, a User Product, and the conveying occurs as part of a transaction in which the right of
- possession and use of the User Product is transferred to the recipient in perpetuity or for
- a fixed term (regardless of how the transaction is characterized), the Corresponding Source
- conveyed under this section must be accompanied by the Installation Information. But this
- requirement does not apply if neither you nor any third party retains the ability to install
- modified object code on the User Product (for example, the work has been installed in ROM).
-
-
-
- The requirement to provide Installation Information does not include a requirement to
- continue to provide support service, warranty, or updates for a work that has been modified
- or installed by the recipient, or for the User Product in which it has been modified or
- installed. Access to a network may be denied when the modification itself materially and
- adversely affects the operation of the network or violates the rules and protocols for
- communication across the network.
-
-
-
- Corresponding Source conveyed, and Installation Information provided, in accord with this
- section must be in a format that is publicly documented (and with an implementation
- available to the public in source code form), and must require no special password or key
- for unpacking, reading or copying.
-
-
-
7. Additional Terms.
-
-
- "Additional permissions" are terms that supplement the terms of this License by
- making exceptions from one or more of its conditions. Additional permissions that are
- applicable to the entire Program shall be treated as though they were included in this
- License, to the extent that they are valid under applicable law. If additional permissions
- apply only to part of the Program, that part may be used separately under those permissions,
- but the entire Program remains governed by this License without regard to the additional
- permissions.
-
-
-
- When you convey a copy of a covered work, you may at your option remove any additional
- permissions from that copy, or from any part of it. (Additional permissions may be written
- to require their own removal in certain cases when you modify the work.) You may place
- additional permissions on material, added by you to a covered work, for which you have or
- can give appropriate copyright permission.
-
-
-
- Notwithstanding any other provision of this License, for material you add to a covered work,
- you may (if authorized by the copyright holders of that material) supplement the terms of
- this License with terms:
-
-
-
-
- a) Disclaiming warranty or limiting liability differently from the terms of sections 15
- and 16 of this License; or
-
-
-
- b) Requiring preservation of specified reasonable legal notices or author attributions in
- that material or in the Appropriate Legal Notices displayed by works containing it; or
-
-
-
- c) Prohibiting misrepresentation of the origin of that material, or requiring that
- modified versions of such material be marked in reasonable ways as different from the
- original version; or
-
-
-
- d) Limiting the use for publicity purposes of names of licensors or authors of the
- material; or
-
-
-
- e) Declining to grant rights under trademark law for use of some trade names, trademarks,
- or service marks; or
-
-
-
- f) Requiring indemnification of licensors and authors of that material by anyone who
- conveys the material (or modified versions of it) with contractual assumptions of
- liability to the recipient, for any liability that these contractual assumptions directly
- impose on those licensors and authors.
-
-
-
-
- All other non-permissive additional terms are considered "further restrictions"
- within the meaning of section 10. If the Program as you received it, or any part of it,
- contains a notice stating that it is governed by this License along with a term that is a
- further restriction, you may remove that term. If a license document contains a further
- restriction but permits relicensing or conveying under this License, you may add to a
- covered work material governed by the terms of that license document, provided that the
- further restriction does not survive such relicensing or conveying.
-
-
-
- If you add terms to a covered work in accord with this section, you must place, in the
- relevant source files, a statement of the additional terms that apply to those files, or a
- notice indicating where to find the applicable terms.
-
-
-
- Additional terms, permissive or non-permissive, may be stated in the form of a separately
- written license, or stated as exceptions; the above requirements apply either way.
-
-
-
8. Termination.
-
-
- You may not propagate or modify a covered work except as expressly provided under this
- License. Any attempt otherwise to propagate or modify it is void, and will automatically
- terminate your rights under this License (including any patent licenses granted under the
- third paragraph of section 11).
-
-
-
- However, if you cease all violation of this License, then your license from a particular
- copyright holder is reinstated (a) provisionally, unless and until the copyright holder
- explicitly and finally terminates your license, and (b) permanently, if the copyright holder
- fails to notify you of the violation by some reasonable means prior to 60 days after the
- cessation.
-
-
-
- Moreover, your license from a particular copyright holder is reinstated permanently if the
- copyright holder notifies you of the violation by some reasonable means, this is the first
- time you have received notice of violation of this License (for any work) from that
- copyright holder, and you cure the violation prior to 30 days after your receipt of the
- notice.
-
-
-
- Termination of your rights under this section does not terminate the licenses of parties who
- have received copies or rights from you under this License. If your rights have been
- terminated and not permanently reinstated, you do not qualify to receive new licenses for
- the same material under section 10.
-
-
-
9. Acceptance Not Required for Having Copies.
-
-
- You are not required to accept this License in order to receive or run a copy of the
- Program. Ancillary propagation of a covered work occurring solely as a consequence of using
- peer-to-peer transmission to receive a copy likewise does not require acceptance. However,
- nothing other than this License grants you permission to propagate or modify any covered
- work. These actions infringe copyright if you do not accept this License. Therefore, by
- modifying or propagating a covered work, you indicate your acceptance of this License to do
- so.
-
-
-
10. Automatic Licensing of Downstream Recipients.
-
-
- Each time you convey a covered work, the recipient automatically receives a license from the
- original licensors, to run, modify and propagate that work, subject to this License. You are
- not responsible for enforcing compliance by third parties with this License.
-
-
-
- An "entity transaction" is a transaction transferring control of an organization,
- or substantially all assets of one, or subdividing an organization, or merging
- organizations. If propagation of a covered work results from an entity transaction, each
- party to that transaction who receives a copy of the work also receives whatever licenses to
- the work the party's predecessor in interest had or could give under the previous
- paragraph, plus a right to possession of the Corresponding Source of the work from the
- predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
-
-
-
- You may not impose any further restrictions on the exercise of the rights granted or
- affirmed under this License. For example, you may not impose a license fee, royalty, or
- other charge for exercise of rights granted under this License, and you may not initiate
- litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent
- claim is infringed by making, using, selling, offering for sale, or importing the Program or
- any portion of it.
-
-
-
11. Patents.
-
-
- A "contributor" is a copyright holder who authorizes use under this License of the
- Program or a work on which the Program is based. The work thus licensed is called the
- contributor's "contributor version".
-
-
-
- A contributor's "essential patent claims" are all patent claims owned or
- controlled by the contributor, whether already acquired or hereafter acquired, that would be
- infringed by some manner, permitted by this License, of making, using, or selling its
- contributor version, but do not include claims that would be infringed only as a consequence
- of further modification of the contributor version. For purposes of this definition,
- "control" includes the right to grant patent sublicenses in a manner consistent
- with the requirements of this License.
-
-
-
- Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under
- the contributor's essential patent claims, to make, use, sell, offer for sale, import
- and otherwise run, modify and propagate the contents of its contributor version.
-
-
-
- In the following three paragraphs, a "patent license" is any express agreement or
- commitment, however denominated, not to enforce a patent (such as an express permission to
- practice a patent or covenant not to sue for patent infringement). To "grant" such
- a patent license to a party means to make such an agreement or commitment not to enforce a
- patent against the party.
-
-
-
- If you convey a covered work, knowingly relying on a patent license, and the Corresponding
- Source of the work is not available for anyone to copy, free of charge and under the terms
- of this License, through a publicly available network server or other readily accessible
- means, then you must either (1) cause the Corresponding Source to be so available, or (2)
- arrange to deprive yourself of the benefit of the patent license for this particular work,
- or (3) arrange, in a manner consistent with the requirements of this License, to extend the
- patent license to downstream recipients. "Knowingly relying" means you have actual
- knowledge that, but for the patent license, your conveying the covered work in a country, or
- your recipient's use of the covered work in a country, would infringe one or more
- identifiable patents in that country that you have reason to believe are valid.
-
-
-
- If, pursuant to or in connection with a single transaction or arrangement, you convey, or
- propagate by procuring conveyance of, a covered work, and grant a patent license to some of
- the parties receiving the covered work authorizing them to use, propagate, modify or convey
- a specific copy of the covered work, then the patent license you grant is automatically
- extended to all recipients of the covered work and works based on it.
-
-
-
- A patent license is "discriminatory" if it does not include within the scope of
- its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or
- more of the rights that are specifically granted under this License. You may not convey a
- covered work if you are a party to an arrangement with a third party that is in the business
- of distributing software, under which you make payment to the third party based on the
- extent of your activity of conveying the work, and under which the third party grants, to
- any of the parties who would receive the covered work from you, a discriminatory patent
- license (a) in connection with copies of the covered work conveyed by you (or copies made
- from those copies), or (b) primarily for and in connection with specific products or
- compilations that contain the covered work, unless you entered into that arrangement, or
- that patent license was granted, prior to 28 March 2007.
-
-
-
- Nothing in this License shall be construed as excluding or limiting any implied license or
- other defenses to infringement that may otherwise be available to you under applicable
- patent law.
-
-
-
12. No Surrender of Others' Freedom.
-
-
- If conditions are imposed on you (whether by court order, agreement or otherwise) that
- contradict the conditions of this License, they do not excuse you from the conditions of
- this License. If you cannot convey a covered work so as to satisfy simultaneously your
- obligations under this License and any other pertinent obligations, then as a consequence
- you may not convey it at all. For example, if you agree to terms that obligate you to
- collect a royalty for further conveying from those to whom you convey the Program, the only
- way you could satisfy both those terms and this License would be to refrain entirely from
- conveying the Program.
-
-
-
- 13. Remote Network Interaction; Use with the GNU General Public License.
-
-
-
- Notwithstanding any other provision of this License, if you modify the Program, your
- modified version must prominently offer all users interacting with it remotely through a
- computer network (if your version supports such interaction) an opportunity to receive the
- Corresponding Source of your version by providing access to the Corresponding Source from a
- network server at no charge, through some standard or customary means of facilitating
- copying of software. This Corresponding Source shall include the Corresponding Source for
- any work covered by version 3 of the GNU General Public License that is incorporated
- pursuant to the following paragraph.
-
-
-
- Notwithstanding any other provision of this License, you have permission to link or combine
- any covered work with a work licensed under version 3 of the GNU General Public License into
- a single combined work, and to convey the resulting work. The terms of this License will
- continue to apply to the part which is the covered work, but the work with which it is
- combined will remain governed by version 3 of the GNU General Public License.
-
-
-
14. Revised Versions of this License.
-
-
- The Free Software Foundation may publish revised and/or new versions of the GNU Affero
- General Public License from time to time. Such new versions will be similar in spirit to the
- present version, but may differ in detail to address new problems or concerns.
-
-
-
- Each version is given a distinguishing version number. If the Program specifies that a
- certain numbered version of the GNU Affero General Public License "or any later
- version" applies to it, you have the option of following the terms and conditions
- either of that numbered version or of any later version published by the Free Software
- Foundation. If the Program does not specify a version number of the GNU Affero General
- Public License, you may choose any version ever published by the Free Software Foundation.
-
-
-
- If the Program specifies that a proxy can decide which future versions of the GNU Affero
- General Public License can be used, that proxy's public statement of acceptance of a
- version permanently authorizes you to choose that version for the Program.
-
-
-
- Later license versions may give you additional or different permissions. However, no
- additional obligations are imposed on any author or copyright holder as a result of your
- choosing to follow a later version.
-
-
-
15. Disclaimer of Warranty.
-
-
- THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
- OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM
- "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT
- NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
- PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU.
- SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR
- OR CORRECTION.
-
-
-
16. Limitation of Liability.
-
-
- IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT
- HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE
- LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL
- DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO
- LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES
- OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR
- OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
-
-
-
17. Interpretation of Sections 15 and 16.
-
-
- If the disclaimer of warranty and limitation of liability provided above cannot be given
- local legal effect according to their terms, reviewing courts shall apply local law that
- most closely approximates an absolute waiver of all civil liability in connection with the
- Program, unless a warranty or assumption of liability accompanies a copy of the Program in
- return for a fee.
-
-
- );
-};
-
-Page.getLayout = (page) => {page};
-
-export default Page;
diff --git a/src/pages/license.jsx b/src/pages/license.jsx
new file mode 100644
index 000000000000..3e4bdff52bab
--- /dev/null
+++ b/src/pages/license.jsx
@@ -0,0 +1,714 @@
+import { Container } from "@mui/system";
+import { Layout as DashboardLayout } from "../layouts/index";
+import { Link } from "@mui/material";
+
+const Page = () => {
+ const pageTitle = "License";
+
+ return (
+
+
+ The GNU Affero General Public License is a free, copyleft license for software and other
+ kinds of works, specifically designed to ensure cooperation with the community in the case
+ of network server software.
+
+
+
+ The licenses for most software and other practical works are designed to take away your
+ freedom to share and change the works. By contrast, our General Public Licenses are intended
+ to guarantee your freedom to share and change all versions of a program--to make sure it
+ remains free software for all its users.
+
+
+
+ When we speak of free software, we are referring to freedom, not price. Our General Public
+ Licenses are designed to make sure that you have the freedom to distribute copies of free
+ software (and charge for them if you wish), that you receive source code or can get it if
+ you want it, that you can change the software or use pieces of it in new free programs, and
+ that you know you can do these things.
+
+
+
+ Developers that use our General Public Licenses protect your rights with two steps: (1)
+ assert copyright on the software, and (2) offer you this License which gives you legal
+ permission to copy, distribute and/or modify the software.
+
+
+
+ A secondary benefit of defending all users' freedom is that improvements made in
+ alternate versions of the program, if they receive widespread use, become available for
+ other developers to incorporate. Many developers of free software are heartened and
+ encouraged by the resulting cooperation. However, in the case of software used on network
+ servers, this result may fail to come about. The GNU General Public License permits making a
+ modified version and letting the public access it on a server without ever releasing its
+ source code to the public.
+
+
+
+ The GNU Affero General Public License is designed specifically to ensure that, in such
+ cases, the modified source code becomes available to the community. It requires the operator
+ of a network server to provide the source code of the modified version running there to the
+ users of that server. Therefore, public use of a modified version, on a publicly accessible
+ server, gives the public access to the source code of the modified version.
+
+
+
+ An older license, called the Affero General Public License and published by Affero, was
+ designed to accomplish similar goals. This is a different license, not a version of the
+ Affero GPL, but Affero has released a new version of the Affero GPL which permits
+ relicensing under this license.
+
+
+
The precise terms and conditions for copying, distribution and modification follow.
+
+
TERMS AND CONDITIONS
+
+
0. Definitions.
+
+
"This License" refers to version 3 of the GNU Affero General Public License.
+
+
+ "Copyright" also means copyright-like laws that apply to other kinds of works,
+ such as semiconductor masks.
+
+
+
+ "The Program" refers to any copyrightable work licensed under this License. Each
+ licensee is addressed as "you". "Licensees" and "recipients"
+ may be individuals or organizations.
+
+
+
+ To "modify" a work means to copy from or adapt all or part of the work in a
+ fashion requiring copyright permission, other than the making of an exact copy. The
+ resulting work is called a "modified version" of the earlier work or a work
+ "based on" the earlier work.
+
+
+
+ A "covered work" means either the unmodified Program or a work based on the
+ Program.
+
+
+
+ To "propagate" a work means to do anything with it that, without permission, would
+ make you directly or secondarily liable for infringement under applicable copyright law,
+ except executing it on a computer or modifying a private copy. Propagation includes copying,
+ distribution (with or without modification), making available to the public, and in some
+ countries other activities as well.
+
+
+
+ To "convey" a work means any kind of propagation that enables other parties to
+ make or receive copies. Mere interaction with a user through a computer network, with no
+ transfer of a copy, is not conveying.
+
+
+
+ An interactive user interface displays "Appropriate Legal Notices" to the extent
+ that it includes a convenient and prominently visible feature that (1) displays an
+ appropriate copyright notice, and (2) tells the user that there is no warranty for the work
+ (except to the extent that warranties are provided), that licensees may convey the work
+ under this License, and how to view a copy of this License. If the interface presents a list
+ of user commands or options, such as a menu, a prominent item in the list meets this
+ criterion.
+
+
+
1. Source Code.
+
+
+ The "source code" for a work means the preferred form of the work for making
+ modifications to it. "Object code" means any non-source form of a work.
+
+
+
+ A "Standard Interface" means an interface that either is an official standard
+ defined by a recognized standards body, or, in the case of interfaces specified for a
+ particular programming language, one that is widely used among developers working in that
+ language.
+
+
+
+ The "System Libraries" of an executable work include anything, other than the work
+ as a whole, that (a) is included in the normal form of packaging a Major Component, but
+ which is not part of that Major Component, and (b) serves only to enable use of the work
+ with that Major Component, or to implement a Standard Interface for which an implementation
+ is available to the public in source code form. A "Major Component", in this
+ context, means a major essential component (kernel, window system, and so on) of the
+ specific operating system (if any) on which the executable work runs, or a compiler used to
+ produce the work, or an object code interpreter used to run it.
+
+
+
+ The "Corresponding Source" for a work in object code form means all the source
+ code needed to generate, install, and (for an executable work) run the object code and to
+ modify the work, including scripts to control those activities. However, it does not include
+ the work's System Libraries, or general-purpose tools or generally available free
+ programs which are used unmodified in performing those activities but which are not part of
+ the work. For example, Corresponding Source includes interface definition files associated
+ with source files for the work, and the source code for shared libraries and dynamically
+ linked subprograms that the work is specifically designed to require, such as by intimate
+ data communication or control flow between those subprograms and other parts of the work.
+
+
+
+ The Corresponding Source need not include anything that users can regenerate automatically
+ from other parts of the Corresponding Source.
+
+
+
The Corresponding Source for a work in source code form is that same work.
+
+
2. Basic Permissions.
+
+
+ All rights granted under this License are granted for the term of copyright on the Program,
+ and are irrevocable provided the stated conditions are met. This License explicitly affirms
+ your unlimited permission to run the unmodified Program. The output from running a covered
+ work is covered by this License only if the output, given its content, constitutes a covered
+ work. This License acknowledges your rights of fair use or other equivalent, as provided by
+ copyright law.
+
+
+
+ You may make, run and propagate covered works that you do not convey, without conditions so
+ long as your license otherwise remains in force. You may convey covered works to others for
+ the sole purpose of having them make modifications exclusively for you, or provide you with
+ facilities for running those works, provided that you comply with the terms of this License
+ in conveying all material for which you do not control copyright. Those thus making or
+ running the covered works for you must do so exclusively on your behalf, under your
+ direction and control, on terms that prohibit them from making any copies of your
+ copyrighted material outside their relationship with you.
+
+
+
+ Conveying under any other circumstances is permitted solely under the conditions stated
+ below. Sublicensing is not allowed; section 10 makes it unnecessary.
+
+
+
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+
+ No covered work shall be deemed part of an effective technological measure under any
+ applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted
+ on 20 December 1996, or similar laws prohibiting or restricting circumvention of such
+ measures.
+
+
+
+ When you convey a covered work, you waive any legal power to forbid circumvention of
+ technological measures to the extent such circumvention is effected by exercising rights
+ under this License with respect to the covered work, and you disclaim any intention to limit
+ operation or modification of the work as a means of enforcing, against the work's
+ users, your or third parties' legal rights to forbid circumvention of technological
+ measures.
+
+
+
4. Conveying Verbatim Copies.
+
+
+ You may convey verbatim copies of the Program's source code as you receive it, in any
+ medium, provided that you conspicuously and appropriately publish on each copy an
+ appropriate copyright notice; keep intact all notices stating that this License and any
+ non-permissive terms added in accord with section 7 apply to the code; keep intact all
+ notices of the absence of any warranty; and give all recipients a copy of this License along
+ with the Program.
+
+
+
+ You may charge any price or no price for each copy that you convey, and you may offer
+ support or warranty protection for a fee.
+
+
+
5. Conveying Modified Source Versions.
+
+
+ You may convey a work based on the Program, or the modifications to produce it from the
+ Program, in the form of source code under the terms of section 4, provided that you also
+ meet all of these conditions:
+
+
+
+
+ a) The work must carry prominent notices stating that you modified it, and giving a
+ relevant date.
+
+
+
+ b) The work must carry prominent notices stating that it is released under this License
+ and any conditions added under section 7. This requirement modifies the requirement in
+ section 4 to "keep intact all notices".
+
+
+
+ c) You must license the entire work, as a whole, under this License to anyone who comes
+ into possession of a copy. This License will therefore apply, along with any applicable
+ section 7 additional terms, to the whole of the work, and all its parts, regardless of how
+ they are packaged. This License gives no permission to license the work in any other way,
+ but it does not invalidate such permission if you have separately received it.
+
+
+
+ d) If the work has interactive user interfaces, each must display Appropriate Legal
+ Notices; however, if the Program has interactive interfaces that do not display
+ Appropriate Legal Notices, your work need not make them do so.
+
+
+
+
+ A compilation of a covered work with other separate and independent works, which are not by
+ their nature extensions of the covered work, and which are not combined with it such as to
+ form a larger program, in or on a volume of a storage or distribution medium, is called an
+ "aggregate" if the compilation and its resulting copyright are not used to limit
+ the access or legal rights of the compilation's users beyond what the individual works
+ permit. Inclusion of a covered work in an aggregate does not cause this License to apply to
+ the other parts of the aggregate.
+
+
+
6. Conveying Non-Source Forms.
+
+
+ You may convey a covered work in object code form under the terms of sections 4 and 5,
+ provided that you also convey the machine-readable Corresponding Source under the terms of
+ this License, in one of these ways:
+
+
+
+
+ a) Convey the object code in, or embodied in, a physical product (including a physical
+ distribution medium), accompanied by the Corresponding Source fixed on a durable physical
+ medium customarily used for software interchange.
+
+
+
+ b) Convey the object code in, or embodied in, a physical product (including a physical
+ distribution medium), accompanied by a written offer, valid for at least three years and
+ valid for as long as you offer spare parts or customer support for that product model, to
+ give anyone who possesses the object code either (1) a copy of the Corresponding Source
+ for all the software in the product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no more than your reasonable
+ cost of physically performing this conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+
+
+ c) Convey individual copies of the object code with a copy of the written offer to provide
+ the Corresponding Source. This alternative is allowed only occasionally and
+ noncommercially, and only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+
+
+ d) Convey the object code by offering access from a designated place (gratis or for a
+ charge), and offer equivalent access to the Corresponding Source in the same way through
+ the same place at no further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to copy the object code is a
+ network server, the Corresponding Source may be on a different server (operated by you or
+ a third party) that supports equivalent copying facilities, provided you maintain clear
+ directions next to the object code saying where to find the Corresponding Source.
+ Regardless of what server hosts the Corresponding Source, you remain obligated to ensure
+ that it is available for as long as needed to satisfy these requirements.
+
+
+
+ e) Convey the object code using peer-to-peer transmission, provided you inform other peers
+ where the object code and Corresponding Source of the work are being offered to the
+ general public at no charge under subsection 6d.
+
+
+
+
+ A separable portion of the object code, whose source code is excluded from the Corresponding
+ Source as a System Library, need not be included in conveying the object code work.
+
+
+
+ A "User Product" is either (1) a "consumer product", which means any
+ tangible personal property which is normally used for personal, family, or household
+ purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining
+ whether a product is a consumer product, doubtful cases shall be resolved in favor of
+ coverage. For a particular product received by a particular user, "normally used"
+ refers to a typical or common use of that class of product, regardless of the status of the
+ particular user or of the way in which the particular user actually uses, or expects or is
+ expected to use, the product. A product is a consumer product regardless of whether the
+ product has substantial commercial, industrial or non-consumer uses, unless such uses
+ represent the only significant mode of use of the product.
+
+
+
+ "Installation Information" for a User Product means any methods, procedures,
+ authorization keys, or other information required to install and execute modified versions
+ of a covered work in that User Product from a modified version of its Corresponding Source.
+ The information must suffice to ensure that the continued functioning of the modified object
+ code is in no case prevented or interfered with solely because modification has been made.
+
+
+
+ If you convey an object code work under this section in, or with, or specifically for use
+ in, a User Product, and the conveying occurs as part of a transaction in which the right of
+ possession and use of the User Product is transferred to the recipient in perpetuity or for
+ a fixed term (regardless of how the transaction is characterized), the Corresponding Source
+ conveyed under this section must be accompanied by the Installation Information. But this
+ requirement does not apply if neither you nor any third party retains the ability to install
+ modified object code on the User Product (for example, the work has been installed in ROM).
+
+
+
+ The requirement to provide Installation Information does not include a requirement to
+ continue to provide support service, warranty, or updates for a work that has been modified
+ or installed by the recipient, or for the User Product in which it has been modified or
+ installed. Access to a network may be denied when the modification itself materially and
+ adversely affects the operation of the network or violates the rules and protocols for
+ communication across the network.
+
+
+
+ Corresponding Source conveyed, and Installation Information provided, in accord with this
+ section must be in a format that is publicly documented (and with an implementation
+ available to the public in source code form), and must require no special password or key
+ for unpacking, reading or copying.
+
+
+
7. Additional Terms.
+
+
+ "Additional permissions" are terms that supplement the terms of this License by
+ making exceptions from one or more of its conditions. Additional permissions that are
+ applicable to the entire Program shall be treated as though they were included in this
+ License, to the extent that they are valid under applicable law. If additional permissions
+ apply only to part of the Program, that part may be used separately under those permissions,
+ but the entire Program remains governed by this License without regard to the additional
+ permissions.
+
+
+
+ When you convey a copy of a covered work, you may at your option remove any additional
+ permissions from that copy, or from any part of it. (Additional permissions may be written
+ to require their own removal in certain cases when you modify the work.) You may place
+ additional permissions on material, added by you to a covered work, for which you have or
+ can give appropriate copyright permission.
+
+
+
+ Notwithstanding any other provision of this License, for material you add to a covered work,
+ you may (if authorized by the copyright holders of that material) supplement the terms of
+ this License with terms:
+
+
+
+
+ a) Disclaiming warranty or limiting liability differently from the terms of sections 15
+ and 16 of this License; or
+
+
+
+ b) Requiring preservation of specified reasonable legal notices or author attributions in
+ that material or in the Appropriate Legal Notices displayed by works containing it; or
+
+
+
+ c) Prohibiting misrepresentation of the origin of that material, or requiring that
+ modified versions of such material be marked in reasonable ways as different from the
+ original version; or
+
+
+
+ d) Limiting the use for publicity purposes of names of licensors or authors of the
+ material; or
+
+
+
+ e) Declining to grant rights under trademark law for use of some trade names, trademarks,
+ or service marks; or
+
+
+
+ f) Requiring indemnification of licensors and authors of that material by anyone who
+ conveys the material (or modified versions of it) with contractual assumptions of
+ liability to the recipient, for any liability that these contractual assumptions directly
+ impose on those licensors and authors.
+
+
+
+
+ All other non-permissive additional terms are considered "further restrictions"
+ within the meaning of section 10. If the Program as you received it, or any part of it,
+ contains a notice stating that it is governed by this License along with a term that is a
+ further restriction, you may remove that term. If a license document contains a further
+ restriction but permits relicensing or conveying under this License, you may add to a
+ covered work material governed by the terms of that license document, provided that the
+ further restriction does not survive such relicensing or conveying.
+
+
+
+ If you add terms to a covered work in accord with this section, you must place, in the
+ relevant source files, a statement of the additional terms that apply to those files, or a
+ notice indicating where to find the applicable terms.
+
+
+
+ Additional terms, permissive or non-permissive, may be stated in the form of a separately
+ written license, or stated as exceptions; the above requirements apply either way.
+
+
+
8. Termination.
+
+
+ You may not propagate or modify a covered work except as expressly provided under this
+ License. Any attempt otherwise to propagate or modify it is void, and will automatically
+ terminate your rights under this License (including any patent licenses granted under the
+ third paragraph of section 11).
+
+
+
+ However, if you cease all violation of this License, then your license from a particular
+ copyright holder is reinstated (a) provisionally, unless and until the copyright holder
+ explicitly and finally terminates your license, and (b) permanently, if the copyright holder
+ fails to notify you of the violation by some reasonable means prior to 60 days after the
+ cessation.
+
+
+
+ Moreover, your license from a particular copyright holder is reinstated permanently if the
+ copyright holder notifies you of the violation by some reasonable means, this is the first
+ time you have received notice of violation of this License (for any work) from that
+ copyright holder, and you cure the violation prior to 30 days after your receipt of the
+ notice.
+
+
+
+ Termination of your rights under this section does not terminate the licenses of parties who
+ have received copies or rights from you under this License. If your rights have been
+ terminated and not permanently reinstated, you do not qualify to receive new licenses for
+ the same material under section 10.
+
+
+
9. Acceptance Not Required for Having Copies.
+
+
+ You are not required to accept this License in order to receive or run a copy of the
+ Program. Ancillary propagation of a covered work occurring solely as a consequence of using
+ peer-to-peer transmission to receive a copy likewise does not require acceptance. However,
+ nothing other than this License grants you permission to propagate or modify any covered
+ work. These actions infringe copyright if you do not accept this License. Therefore, by
+ modifying or propagating a covered work, you indicate your acceptance of this License to do
+ so.
+
+
+
10. Automatic Licensing of Downstream Recipients.
+
+
+ Each time you convey a covered work, the recipient automatically receives a license from the
+ original licensors, to run, modify and propagate that work, subject to this License. You are
+ not responsible for enforcing compliance by third parties with this License.
+
+
+
+ An "entity transaction" is a transaction transferring control of an organization,
+ or substantially all assets of one, or subdividing an organization, or merging
+ organizations. If propagation of a covered work results from an entity transaction, each
+ party to that transaction who receives a copy of the work also receives whatever licenses to
+ the work the party's predecessor in interest had or could give under the previous
+ paragraph, plus a right to possession of the Corresponding Source of the work from the
+ predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
+
+
+
+ You may not impose any further restrictions on the exercise of the rights granted or
+ affirmed under this License. For example, you may not impose a license fee, royalty, or
+ other charge for exercise of rights granted under this License, and you may not initiate
+ litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent
+ claim is infringed by making, using, selling, offering for sale, or importing the Program or
+ any portion of it.
+
+
+
11. Patents.
+
+
+ A "contributor" is a copyright holder who authorizes use under this License of the
+ Program or a work on which the Program is based. The work thus licensed is called the
+ contributor's "contributor version".
+
+
+
+ A contributor's "essential patent claims" are all patent claims owned or
+ controlled by the contributor, whether already acquired or hereafter acquired, that would be
+ infringed by some manner, permitted by this License, of making, using, or selling its
+ contributor version, but do not include claims that would be infringed only as a consequence
+ of further modification of the contributor version. For purposes of this definition,
+ "control" includes the right to grant patent sublicenses in a manner consistent
+ with the requirements of this License.
+
+
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under
+ the contributor's essential patent claims, to make, use, sell, offer for sale, import
+ and otherwise run, modify and propagate the contents of its contributor version.
+
+
+
+ In the following three paragraphs, a "patent license" is any express agreement or
+ commitment, however denominated, not to enforce a patent (such as an express permission to
+ practice a patent or covenant not to sue for patent infringement). To "grant" such
+ a patent license to a party means to make such an agreement or commitment not to enforce a
+ patent against the party.
+
+
+
+ If you convey a covered work, knowingly relying on a patent license, and the Corresponding
+ Source of the work is not available for anyone to copy, free of charge and under the terms
+ of this License, through a publicly available network server or other readily accessible
+ means, then you must either (1) cause the Corresponding Source to be so available, or (2)
+ arrange to deprive yourself of the benefit of the patent license for this particular work,
+ or (3) arrange, in a manner consistent with the requirements of this License, to extend the
+ patent license to downstream recipients. "Knowingly relying" means you have actual
+ knowledge that, but for the patent license, your conveying the covered work in a country, or
+ your recipient's use of the covered work in a country, would infringe one or more
+ identifiable patents in that country that you have reason to believe are valid.
+
+
+
+ If, pursuant to or in connection with a single transaction or arrangement, you convey, or
+ propagate by procuring conveyance of, a covered work, and grant a patent license to some of
+ the parties receiving the covered work authorizing them to use, propagate, modify or convey
+ a specific copy of the covered work, then the patent license you grant is automatically
+ extended to all recipients of the covered work and works based on it.
+
+
+
+ A patent license is "discriminatory" if it does not include within the scope of
+ its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or
+ more of the rights that are specifically granted under this License. You may not convey a
+ covered work if you are a party to an arrangement with a third party that is in the business
+ of distributing software, under which you make payment to the third party based on the
+ extent of your activity of conveying the work, and under which the third party grants, to
+ any of the parties who would receive the covered work from you, a discriminatory patent
+ license (a) in connection with copies of the covered work conveyed by you (or copies made
+ from those copies), or (b) primarily for and in connection with specific products or
+ compilations that contain the covered work, unless you entered into that arrangement, or
+ that patent license was granted, prior to 28 March 2007.
+
+
+
+ Nothing in this License shall be construed as excluding or limiting any implied license or
+ other defenses to infringement that may otherwise be available to you under applicable
+ patent law.
+
+
+
12. No Surrender of Others' Freedom.
+
+
+ If conditions are imposed on you (whether by court order, agreement or otherwise) that
+ contradict the conditions of this License, they do not excuse you from the conditions of
+ this License. If you cannot convey a covered work so as to satisfy simultaneously your
+ obligations under this License and any other pertinent obligations, then as a consequence
+ you may not convey it at all. For example, if you agree to terms that obligate you to
+ collect a royalty for further conveying from those to whom you convey the Program, the only
+ way you could satisfy both those terms and this License would be to refrain entirely from
+ conveying the Program.
+
+
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+
+
+ Notwithstanding any other provision of this License, if you modify the Program, your
+ modified version must prominently offer all users interacting with it remotely through a
+ computer network (if your version supports such interaction) an opportunity to receive the
+ Corresponding Source of your version by providing access to the Corresponding Source from a
+ network server at no charge, through some standard or customary means of facilitating
+ copying of software. This Corresponding Source shall include the Corresponding Source for
+ any work covered by version 3 of the GNU General Public License that is incorporated
+ pursuant to the following paragraph.
+
+
+
+ Notwithstanding any other provision of this License, you have permission to link or combine
+ any covered work with a work licensed under version 3 of the GNU General Public License into
+ a single combined work, and to convey the resulting work. The terms of this License will
+ continue to apply to the part which is the covered work, but the work with which it is
+ combined will remain governed by version 3 of the GNU General Public License.
+
+
+
14. Revised Versions of this License.
+
+
+ The Free Software Foundation may publish revised and/or new versions of the GNU Affero
+ General Public License from time to time. Such new versions will be similar in spirit to the
+ present version, but may differ in detail to address new problems or concerns.
+
+
+
+ Each version is given a distinguishing version number. If the Program specifies that a
+ certain numbered version of the GNU Affero General Public License "or any later
+ version" applies to it, you have the option of following the terms and conditions
+ either of that numbered version or of any later version published by the Free Software
+ Foundation. If the Program does not specify a version number of the GNU Affero General
+ Public License, you may choose any version ever published by the Free Software Foundation.
+
+
+
+ If the Program specifies that a proxy can decide which future versions of the GNU Affero
+ General Public License can be used, that proxy's public statement of acceptance of a
+ version permanently authorizes you to choose that version for the Program.
+
+
+
+ Later license versions may give you additional or different permissions. However, no
+ additional obligations are imposed on any author or copyright holder as a result of your
+ choosing to follow a later version.
+
+
+
15. Disclaimer of Warranty.
+
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
+ OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM
+ "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT
+ NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU.
+ SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR
+ OR CORRECTION.
+
+
+
16. Limitation of Liability.
+
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT
+ HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE
+ LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL
+ DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO
+ LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES
+ OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR
+ OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
+
+
+
17. Interpretation of Sections 15 and 16.
+
+
+ If the disclaimer of warranty and limitation of liability provided above cannot be given
+ local legal effect according to their terms, reviewing courts shall apply local law that
+ most closely approximates an absolute waiver of all civil liability in connection with the
+ Program, unless a warranty or assumption of liability accompanies a copy of the Program in
+ return for a fee.
+
This is a placeholder page for the tenant lookup section.
+
+ );
+};
+
+Page.getLayout = (page) => {page};
+
+export default Page;
diff --git a/src/pages/tenant/administration/tenants/add.js b/src/pages/tenant/administration/tenants/add.js
deleted file mode 100644
index 74c36bb424d0..000000000000
--- a/src/pages/tenant/administration/tenants/add.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import { Layout as DashboardLayout } from "../../../../layouts/index.js";
-import CippWizardPage from "../../../../components/CippWizard/CippWizardPage.jsx";
-import { CippWizardOptionsList } from "../../../../components/CippWizard/CippWizardOptionsList.jsx";
-import { CippAddTenantForm } from "../../../../components/CippWizard/CippAddTenantForm.jsx";
-import { BuildingOfficeIcon } from "@heroicons/react/24/outline";
-import CippWizardConfirmation from "../../../../components/CippWizard/CippWizardConfirmation.jsx";
-
-const Page = () => {
- const steps = [
- {
- title: "Step 1",
- description: "Tenant Type",
- component: CippWizardOptionsList,
- componentProps: {
- name: "TenantType",
- title: "New Tenant Deployment",
- subtext: `Choose the type of tenant you would like to deploy.`,
- valuesKey: "TenantType",
- options: [
- {
- description: "I would like to deploy a new tenant for my customer",
- icon: ,
- label: "Customer Tenant",
- value: "CustomerTenant",
- },
- ],
- },
- },
- {
- title: "Step 2",
- description: "Enter Tenant Details",
- component: CippAddTenantForm,
- },
- {
- title: "Step 3",
- description: "Confirm and Submit",
- component: CippWizardConfirmation,
- },
- ];
-
- return (
- <>
-
- >
- );
-};
-
-Page.getLayout = (page) => {page};
-
-export default Page;
diff --git a/src/pages/tenant/administration/tenants/add.jsx b/src/pages/tenant/administration/tenants/add.jsx
new file mode 100644
index 000000000000..41ab4b4ee77e
--- /dev/null
+++ b/src/pages/tenant/administration/tenants/add.jsx
@@ -0,0 +1,64 @@
+import { useEffect } from "react";
+import { useRouter } from "next/router";
+import { Layout as DashboardLayout } from "../../../../layouts/index";
+import CippWizardPage from "../../../../components/CippWizard/CippWizardPage.jsx";
+import { CippWizardOptionsList } from "../../../../components/CippWizard/CippWizardOptionsList.jsx";
+import { CippAddTenantForm } from "../../../../components/CippWizard/CippAddTenantForm.jsx";
+import { BuildingOfficeIcon } from "@heroicons/react/24/outline";
+import CippWizardConfirmation from "../../../../components/CippWizard/CippWizardConfirmation.jsx";
+
+const Page = () => {
+ // Tenant onboarding runs through the setup wizard, so send visitors there.
+ const router = useRouter();
+ useEffect(() => {
+ if (!router.isReady) return;
+ router.replace("/onboardingv2");
+ }, [router]);
+
+ const steps = [
+ {
+ title: "Step 1",
+ description: "Tenant Type",
+ component: CippWizardOptionsList,
+ componentProps: {
+ name: "TenantType",
+ title: "New Tenant Deployment",
+ subtext: `Choose the type of tenant you would like to deploy.`,
+ valuesKey: "TenantType",
+ options: [
+ {
+ description: "I would like to deploy a new tenant for my customer",
+ icon: ,
+ label: "Customer Tenant",
+ value: "CustomerTenant",
+ },
+ ],
+ },
+ },
+ {
+ title: "Step 2",
+ description: "Enter Tenant Details",
+ component: CippAddTenantForm,
+ },
+ {
+ title: "Step 3",
+ description: "Confirm and Submit",
+ component: CippWizardConfirmation,
+ },
+ ];
+
+ return (
+ <>
+
+ >
+ );
+};
+
+Page.getLayout = (page) => {page};
+
+export default Page;
diff --git a/src/pages/tenant/administration/tenants/edit.js b/src/pages/tenant/administration/tenants/edit.js
deleted file mode 100644
index f8214e55b6e7..000000000000
--- a/src/pages/tenant/administration/tenants/edit.js
+++ /dev/null
@@ -1,285 +0,0 @@
-import { Layout as DashboardLayout } from "../../../../layouts/index.js";
-import { useForm } from "react-hook-form";
-import { ApiGetCall } from "../../../../api/ApiCall";
-import { useEffect, useState } from "react";
-import { useRouter } from "next/router";
-import CippFormComponent from "../../../../components/CippComponents/CippFormComponent";
-import { Stack, Box, Tab, Tabs, Typography, Button } from "@mui/material";
-import { Grid } from "@mui/system";
-import { CippCardTabPanel } from "../../../../components/CippComponents/CippCardTabPanel";
-import CippFormSection from "../../../../components/CippFormPages/CippFormSection";
-import CippPageCard from "../../../../components/CippCards/CippPageCard";
-import { CippPropertyListCard } from "../../../../components/CippCards/CippPropertyListCard";
-import { getCippFormatting } from "../../../../utils/get-cipp-formatting";
-import CippCustomVariables from "../../../../components/CippComponents/CippCustomVariables";
-import { CippOffboardingDefaultSettings } from "../../../../components/CippComponents/CippOffboardingDefaultSettings";
-
-function tabProps(index) {
- return {
- id: `simple-tab-${index}`,
- "aria-controls": `simple-tabpanel-${index}`,
- };
-}
-
-const Page = () => {
- const router = useRouter();
- const { id } = router.query;
- const formControl = useForm({
- mode: "onChange",
- });
-
- const offboardingFormControl = useForm({
- mode: "onChange",
- });
- const [value, setValue] = useState(0);
-
- const tenantDetails = ApiGetCall({
- url: id ? `/api/ListTenantDetails?tenantFilter=${id}` : null,
- queryKey: id ? `TenantProperties_${id}` : null,
- });
-
- useEffect(() => {
- if (tenantDetails.isSuccess && tenantDetails.data) {
- formControl.reset({
- customerId: id,
- Alias: tenantDetails?.data?.customProperties?.Alias ?? "",
- Groups:
- tenantDetails.data.Groups?.map((group) => ({
- label: group.Name,
- value: group.Id,
- })) || [],
- });
-
- // Set up offboarding defaults with default values
- const tenantOffboardingDefaults = tenantDetails.data?.customProperties?.OffboardingDefaults;
- const defaultOffboardingValues = {
- ConvertToShared: false,
- RemoveGroups: false,
- HideFromGAL: false,
- RemoveLicenses: false,
- removeCalendarInvites: false,
- RevokeSessions: false,
- removePermissions: false,
- RemoveRules: false,
- ResetPass: false,
- KeepCopy: false,
- DeleteUser: false,
- RemoveMobile: false,
- DisableSignIn: false,
- RemoveMFADevices: false,
- RemoveTeamsPhoneDID: false,
- ClearImmutableId: false,
- DisableOneDriveSharing: false,
- removeCalendarPermissions: false,
- OOO: "",
- postExecution: {
- psa: false,
- email: false,
- webhook: false,
- },
- };
-
- let offboardingDefaults = {};
-
- if (tenantOffboardingDefaults) {
- try {
- const parsed = JSON.parse(tenantOffboardingDefaults);
- // Merge defaults with parsed values to ensure all fields are defined
- offboardingDefaults = {
- offboardingDefaults: { ...defaultOffboardingValues, ...parsed }
- };
- } catch {
- offboardingDefaults = { offboardingDefaults: defaultOffboardingValues };
- }
- } else {
- offboardingDefaults = { offboardingDefaults: defaultOffboardingValues };
- }
-
- offboardingFormControl.reset(offboardingDefaults);
- }
- }, [tenantDetails.isSuccess, tenantDetails.data, id]);
-
- const handleTabChange = (event, newValue) => {
- setValue(newValue);
- };
-
- const handleResetOffboardingDefaults = () => {
- const defaultOffboardingValues = {
- ConvertToShared: false,
- RemoveGroups: false,
- HideFromGAL: false,
- RemoveLicenses: false,
- removeCalendarInvites: false,
- RevokeSessions: false,
- removePermissions: false,
- RemoveRules: false,
- ResetPass: false,
- KeepCopy: false,
- DeleteUser: false,
- RemoveMobile: false,
- DisableSignIn: false,
- RemoveMFADevices: false,
- RemoveTeamsPhoneDID: false,
- ClearImmutableId: false,
- DisableOneDriveSharing: false,
- removeCalendarPermissions: false,
- OOO: "",
- postExecution: {
- psa: false,
- email: false,
- webhook: false,
- },
- };
-
- offboardingFormControl.reset({ offboardingDefaults: defaultOffboardingValues });
- };
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {
- const formattedValues = {
- tenantAlias: values.Alias,
- tenantGroups: values.Groups.map((group) => ({
- groupId: group.value,
- groupName: group.label,
- })),
- customerId: id,
- };
- return formattedValues;
- }}
- >
- Properties
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Tenant-Specific Offboarding Defaults
-
- Configure default offboarding settings specifically for this tenant. These settings will override user defaults when offboarding users in this tenant.
-
-
- {
- const offboardingSettings = values.offboardingDefaults || values;
- return {
- customerId: id,
- offboardingDefaults: offboardingSettings,
- };
- }}
- hideTitle={true}
- >
-
-
-
-
- Reset All to Off
-
-
- Click "Reset All to Off" to turn off all options, then click "Save" to clear tenant defaults.
-
-
-
-
-
-
-
-
-
- );
-};
-
-Page.getLayout = (page) => {page};
-
-export default Page;
diff --git a/src/pages/tenant/administration/tenants/edit.jsx b/src/pages/tenant/administration/tenants/edit.jsx
new file mode 100644
index 000000000000..f2a9b6b3f8a0
--- /dev/null
+++ b/src/pages/tenant/administration/tenants/edit.jsx
@@ -0,0 +1,305 @@
+import { Layout as DashboardLayout } from "../../../../layouts/index";
+import { useForm } from "react-hook-form";
+import { ApiGetCall } from "../../../../api/ApiCall";
+import { useEffect, useState } from "react";
+import { useRouter } from "next/router";
+import CippFormComponent from "../../../../components/CippComponents/CippFormComponent";
+import { Alert, Stack, Box, Tab, Tabs, Typography, Button } from "@mui/material";
+import { Grid } from "@mui/system";
+import { CippCardTabPanel } from "../../../../components/CippComponents/CippCardTabPanel";
+import CippFormSection from "../../../../components/CippFormPages/CippFormSection";
+import CippPageCard from "../../../../components/CippCards/CippPageCard";
+import { CippPropertyListCard } from "../../../../components/CippCards/CippPropertyListCard";
+import { getCippFormatting } from "../../../../utils/get-cipp-formatting";
+import CippCustomVariables from "../../../../components/CippComponents/CippCustomVariables";
+import { CippOffboardingDefaultSettings } from "../../../../components/CippComponents/CippOffboardingDefaultSettings";
+
+function tabProps(index) {
+ return {
+ id: `simple-tab-${index}`,
+ "aria-controls": `simple-tabpanel-${index}`,
+ };
+}
+
+const Page = () => {
+ const router = useRouter();
+ const { id } = router.query;
+ const formControl = useForm({
+ mode: "onChange",
+ });
+
+ const offboardingFormControl = useForm({
+ mode: "onChange",
+ });
+ const [value, setValue] = useState(0);
+
+ const tenantDetails = ApiGetCall({
+ url: id ? `/api/ListTenantDetails?tenantFilter=${id}` : null,
+ queryKey: id ? `TenantProperties_${id}` : null,
+ // Opened without a tenant: nothing to fetch, and the title must not read "undefined".
+ waiting: !!id,
+ });
+
+ useEffect(() => {
+ if (tenantDetails.isSuccess && tenantDetails.data) {
+ formControl.reset({
+ customerId: id,
+ Alias: tenantDetails?.data?.customProperties?.Alias ?? "",
+ Groups:
+ tenantDetails.data.Groups?.map((group) => ({
+ label: group.Name,
+ value: group.Id,
+ })) || [],
+ });
+
+ // Set up offboarding defaults with default values
+ const tenantOffboardingDefaults = tenantDetails.data?.customProperties?.OffboardingDefaults;
+ const defaultOffboardingValues = {
+ ConvertToShared: false,
+ RemoveGroups: false,
+ HideFromGAL: false,
+ RemoveLicenses: false,
+ removeCalendarInvites: false,
+ RevokeSessions: false,
+ removePermissions: false,
+ RemoveRules: false,
+ ResetPass: false,
+ KeepCopy: false,
+ DeleteUser: false,
+ RemoveMobile: false,
+ WipeMobile: false,
+ DisableSignIn: false,
+ RemoveMFADevices: false,
+ RemoveTeamsPhoneDID: false,
+ ClearImmutableId: false,
+ DisableOneDriveSharing: false,
+ removeCalendarPermissions: false,
+ OOO: "",
+ postExecution: {
+ psa: false,
+ email: false,
+ webhook: false,
+ },
+ };
+
+ let offboardingDefaults = {};
+
+ if (tenantOffboardingDefaults) {
+ try {
+ const parsed = JSON.parse(tenantOffboardingDefaults);
+ // Merge defaults with parsed values to ensure all fields are defined
+ offboardingDefaults = {
+ offboardingDefaults: { ...defaultOffboardingValues, ...parsed }
+ };
+ } catch {
+ offboardingDefaults = { offboardingDefaults: defaultOffboardingValues };
+ }
+ } else {
+ offboardingDefaults = { offboardingDefaults: defaultOffboardingValues };
+ }
+
+ offboardingFormControl.reset(offboardingDefaults);
+ }
+ }, [tenantDetails.isSuccess, tenantDetails.data, id]);
+
+ const handleTabChange = (event, newValue) => {
+ setValue(newValue);
+ };
+
+ const handleResetOffboardingDefaults = () => {
+ const defaultOffboardingValues = {
+ ConvertToShared: false,
+ RemoveGroups: false,
+ HideFromGAL: false,
+ RemoveLicenses: false,
+ removeCalendarInvites: false,
+ RevokeSessions: false,
+ removePermissions: false,
+ RemoveRules: false,
+ ResetPass: false,
+ KeepCopy: false,
+ DeleteUser: false,
+ RemoveMobile: false,
+ WipeMobile: false,
+ DisableSignIn: false,
+ RemoveMFADevices: false,
+ RemoveTeamsPhoneDID: false,
+ ClearImmutableId: false,
+ DisableOneDriveSharing: false,
+ removeCalendarPermissions: false,
+ OOO: "",
+ postExecution: {
+ psa: false,
+ email: false,
+ webhook: false,
+ },
+ };
+
+ offboardingFormControl.reset({ offboardingDefaults: defaultOffboardingValues });
+ };
+
+ return (
+
+
+ {!id && (
+
+ No tenant selected. Open this page from the Tenants list to edit a tenant.
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ const formattedValues = {
+ tenantAlias: values.Alias,
+ tenantGroups: values.Groups.map((group) => ({
+ groupId: group.value,
+ groupName: group.label,
+ })),
+ customerId: id,
+ };
+ return formattedValues;
+ }}
+ >
+ Properties
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Tenant-Specific Offboarding Defaults
+
+ Configure default offboarding settings specifically for this tenant. These settings will override user defaults when offboarding users in this tenant.
+
+
+ {
+ const offboardingSettings = values.offboardingDefaults || values;
+ return {
+ customerId: id,
+ offboardingDefaults: offboardingSettings,
+ };
+ }}
+ hideTitle={true}
+ >
+
+
+
+
+ Reset All to Off
+
+
+ Click "Reset All to Off" to turn off all options, then click "Save" to clear tenant defaults.
+
+
+
+
+
+
+
+
+
+ );
+};
+
+Page.getLayout = (page) => {page};
+
+export default Page;
diff --git a/src/pages/tenant/administration/tenants/global-variables.js b/src/pages/tenant/administration/tenants/global-variables.js
deleted file mode 100644
index 9ebdb7b8b5ee..000000000000
--- a/src/pages/tenant/administration/tenants/global-variables.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import tabOptions from "./tabOptions";
-import { TabbedLayout } from "../../../../layouts/TabbedLayout";
-import { Layout as DashboardLayout } from "../../../../layouts/index.js";
-import CippCustomVariables from "../../../../components/CippComponents/CippCustomVariables.jsx";
-import CippPageCard from "../../../../components/CippCards/CippPageCard.jsx";
-
-const Page = () => {
- return (
-
-
-
- );
-};
-
-Page.getLayout = (page) => (
-
- {page}
-
-);
-
-export default Page;
diff --git a/src/pages/tenant/administration/tenants/global-variables.jsx b/src/pages/tenant/administration/tenants/global-variables.jsx
new file mode 100644
index 000000000000..ec25dd2a1c26
--- /dev/null
+++ b/src/pages/tenant/administration/tenants/global-variables.jsx
@@ -0,0 +1,21 @@
+import tabOptions from "./tabOptions";
+import { TabbedLayout } from "../../../../layouts/TabbedLayout";
+import { Layout as DashboardLayout } from "../../../../layouts/index";
+import CippCustomVariables from "../../../../components/CippComponents/CippCustomVariables.jsx";
+import CippPageCard from "../../../../components/CippCards/CippPageCard.jsx";
+
+const Page = () => {
+ return (
+
+
+
+ );
+};
+
+Page.getLayout = (page) => (
+
+ {page}
+
+);
+
+export default Page;
diff --git a/src/pages/tenant/administration/tenants/groups/edit.js b/src/pages/tenant/administration/tenants/groups/edit.js
deleted file mode 100644
index a7f6a34b3573..000000000000
--- a/src/pages/tenant/administration/tenants/groups/edit.js
+++ /dev/null
@@ -1,218 +0,0 @@
-import { Layout as DashboardLayout } from "../../../../../layouts/index.js";
-import { useForm } from "react-hook-form";
-import { ApiGetCall } from "../../../../../api/ApiCall";
-import { useEffect } from "react";
-import { useRouter } from "next/router";
-import { Box } from "@mui/material";
-import CippFormPage from "../../../../../components/CippFormPages/CippFormPage";
-import CippAddEditTenantGroups from "../../../../../components/CippComponents/CippAddEditTenantGroups";
-
-const Page = () => {
- const router = useRouter();
- const { id } = router.query;
- const formControl = useForm({
- mode: "onChange",
- });
-
- const groupDetails = ApiGetCall({
- url: id ? `/api/ListTenantGroups?groupId=${id}` : null,
- queryKey: id ? `TenantGroupProperties_${id}` : null,
- });
-
- useEffect(() => {
- if (groupDetails.isSuccess && groupDetails.data) {
- const groupData = groupDetails?.data?.Results?.[0];
-
- // Determine if this is a dynamic or static group
- const isDynamic = groupData?.GroupType === "dynamic" && groupData?.DynamicRules;
-
- // Format dynamic rules if they exist
- let formattedDynamicRules = [{}];
- if (isDynamic && groupData.DynamicRules) {
- try {
- let rules;
- if (Array.isArray(groupData.DynamicRules)) {
- rules = groupData.DynamicRules;
- } else if (typeof groupData.DynamicRules === "string") {
- rules = JSON.parse(groupData.DynamicRules);
- } else if (typeof groupData.DynamicRules === "object") {
- rules = [groupData.DynamicRules];
- } else {
- rules = [];
- }
-
- formattedDynamicRules = rules.map((rule) => {
- // Handle value - it's always an array of objects from the backend
- let valueForForm;
-
- // Special handling for custom variables (nested structure with variableName and value)
- if (
- rule.property === "customVariable" &&
- typeof rule.value === "object" &&
- rule.value?.variableName
- ) {
- valueForForm = {
- variableName: rule.value.variableName,
- value: rule.value.value,
- };
- } else if (rule.property === "gdapRelationshipAge") {
- // Number input bound to value.value - no label/option wrapping
- valueForForm = {
- value: rule.value?.value ?? rule.value,
- };
- } else if (Array.isArray(rule.value)) {
- // If it's an array of objects, extract all values
- valueForForm = rule.value.map((item) => ({
- label: item.label || item.value || item,
- value: item.value || item,
- }));
- // For single selection operators, take just the first item
- if (rule.operator === "eq" || rule.operator === "ne") {
- valueForForm = valueForForm[0];
- }
- } else if (typeof rule.value === "object" && rule.value?.value) {
- // If it's a single object with a value property
- valueForForm = {
- label: rule.value.label || rule.value.value,
- value: rule.value.value,
- };
- } else {
- // Simple value
- valueForForm = {
- label: rule.value,
- value: rule.value,
- };
- }
-
- return {
- property: {
- label:
- rule.property === "availableLicense"
- ? "Available License"
- : rule.property === "availableServicePlan"
- ? "Available Service Plan"
- : rule.property === "delegatedAccessStatus"
- ? "Delegated Access Status"
- : rule.property === "tenantGroupMember"
- ? "Member of Tenant Group"
- : rule.property === "customVariable"
- ? "Custom Variable"
- : rule.property === "gdapRelationshipAge"
- ? "GDAP Relationship Age (days)"
- : rule.property,
- value: rule.property,
- type:
- rule.property === "availableLicense"
- ? "license"
- : rule.property === "availableServicePlan"
- ? "servicePlan"
- : rule.property === "delegatedAccessStatus"
- ? "delegatedAccess"
- : rule.property === "tenantGroupMember"
- ? "tenantGroup"
- : rule.property === "customVariable"
- ? "customVariable"
- : rule.property === "gdapRelationshipAge"
- ? "gdapAge"
- : "unknown",
- },
- operator: {
- label:
- rule.operator === "eq"
- ? "Equals"
- : rule.operator === "ne"
- ? "Not Equals"
- : rule.operator === "in"
- ? "In"
- : rule.operator === "notIn"
- ? "Not In"
- : rule.operator === "like"
- ? "Contains"
- : rule.operator === "notlike"
- ? "Does Not Contain"
- : rule.operator === "gt"
- ? "Greater Than"
- : rule.operator === "ge"
- ? "Greater Than or Equal"
- : rule.operator === "lt"
- ? "Less Than"
- : rule.operator === "le"
- ? "Less Than or Equal"
- : rule.operator,
- value: rule.operator,
- },
- value: valueForForm,
- };
- });
- } catch (e) {
- console.error("Error parsing dynamic rules:", e, groupData.DynamicRules);
- formattedDynamicRules = [{}];
- }
- }
-
- formControl.reset({
- groupId: id,
- groupName: groupData?.Name ?? "",
- groupDescription: groupData?.Description ?? "",
- groupType: isDynamic ? "dynamic" : "static",
- ruleLogic: groupData?.RuleLogic || "and",
- excludePartnerTenant: groupData?.ExcludePartnerTenant ?? false,
- members: !isDynamic
- ? groupData?.Members?.map((member) => ({
- label: member.displayName,
- value: member.customerId,
- })) || []
- : [],
- dynamicRules: formattedDynamicRules,
- });
- }
- }, [groupDetails.isSuccess, groupDetails.data, id]);
-
- const customDataFormatter = (values) => {
- const formattedData = {
- ...values,
- Action: "AddEdit",
- };
-
- // If it's a dynamic group, format the rules for the backend
- if (values.groupType === "dynamic" && values.dynamicRules) {
- formattedData.dynamicRules = values.dynamicRules.map((rule) => ({
- property: rule.property?.value || rule.property,
- operator: rule.operator?.value || rule.operator,
- value: rule.value,
- }));
- formattedData.ruleLogic = values.ruleLogic || "and";
- }
-
- return formattedData;
- };
-
- return (
-
-
-
-
-
- );
-};
-
-Page.getLayout = (page) => {page};
-
-export default Page;
diff --git a/src/pages/tenant/administration/tenants/groups/edit.jsx b/src/pages/tenant/administration/tenants/groups/edit.jsx
new file mode 100644
index 000000000000..5520716a4a4b
--- /dev/null
+++ b/src/pages/tenant/administration/tenants/groups/edit.jsx
@@ -0,0 +1,218 @@
+import { Layout as DashboardLayout } from "../../../../../layouts/index";
+import { useForm } from "react-hook-form";
+import { ApiGetCall } from "../../../../../api/ApiCall";
+import { useEffect } from "react";
+import { useRouter } from "next/router";
+import { Box } from "@mui/material";
+import CippFormPage from "../../../../../components/CippFormPages/CippFormPage";
+import CippAddEditTenantGroups from "../../../../../components/CippComponents/CippAddEditTenantGroups";
+
+const Page = () => {
+ const router = useRouter();
+ const { id } = router.query;
+ const formControl = useForm({
+ mode: "onChange",
+ });
+
+ const groupDetails = ApiGetCall({
+ url: id ? `/api/ListTenantGroups?groupId=${id}` : null,
+ queryKey: id ? `TenantGroupProperties_${id}` : null,
+ });
+
+ useEffect(() => {
+ if (groupDetails.isSuccess && groupDetails.data) {
+ const groupData = groupDetails?.data?.Results?.[0];
+
+ // Determine if this is a dynamic or static group
+ const isDynamic = groupData?.GroupType === "dynamic" && groupData?.DynamicRules;
+
+ // Format dynamic rules if they exist
+ let formattedDynamicRules = [{}];
+ if (isDynamic && groupData.DynamicRules) {
+ try {
+ let rules;
+ if (Array.isArray(groupData.DynamicRules)) {
+ rules = groupData.DynamicRules;
+ } else if (typeof groupData.DynamicRules === "string") {
+ rules = JSON.parse(groupData.DynamicRules);
+ } else if (typeof groupData.DynamicRules === "object") {
+ rules = [groupData.DynamicRules];
+ } else {
+ rules = [];
+ }
+
+ formattedDynamicRules = rules.map((rule) => {
+ // Handle value - it's always an array of objects from the backend
+ let valueForForm;
+
+ // Special handling for custom variables (nested structure with variableName and value)
+ if (
+ rule.property === "customVariable" &&
+ typeof rule.value === "object" &&
+ rule.value?.variableName
+ ) {
+ valueForForm = {
+ variableName: rule.value.variableName,
+ value: rule.value.value,
+ };
+ } else if (rule.property === "gdapRelationshipAge") {
+ // Number input bound to value.value - no label/option wrapping
+ valueForForm = {
+ value: rule.value?.value ?? rule.value,
+ };
+ } else if (Array.isArray(rule.value)) {
+ // If it's an array of objects, extract all values
+ valueForForm = rule.value.map((item) => ({
+ label: item.label || item.value || item,
+ value: item.value || item,
+ }));
+ // For single selection operators, take just the first item
+ if (rule.operator === "eq" || rule.operator === "ne") {
+ valueForForm = valueForForm[0];
+ }
+ } else if (typeof rule.value === "object" && rule.value?.value) {
+ // If it's a single object with a value property
+ valueForForm = {
+ label: rule.value.label || rule.value.value,
+ value: rule.value.value,
+ };
+ } else {
+ // Simple value
+ valueForForm = {
+ label: rule.value,
+ value: rule.value,
+ };
+ }
+
+ return {
+ property: {
+ label:
+ rule.property === "availableLicense"
+ ? "Available License"
+ : rule.property === "availableServicePlan"
+ ? "Available Service Plan"
+ : rule.property === "delegatedAccessStatus"
+ ? "Delegated Access Status"
+ : rule.property === "tenantGroupMember"
+ ? "Member of Tenant Group"
+ : rule.property === "customVariable"
+ ? "Custom Variable"
+ : rule.property === "gdapRelationshipAge"
+ ? "GDAP Relationship Age (days)"
+ : rule.property,
+ value: rule.property,
+ type:
+ rule.property === "availableLicense"
+ ? "license"
+ : rule.property === "availableServicePlan"
+ ? "servicePlan"
+ : rule.property === "delegatedAccessStatus"
+ ? "delegatedAccess"
+ : rule.property === "tenantGroupMember"
+ ? "tenantGroup"
+ : rule.property === "customVariable"
+ ? "customVariable"
+ : rule.property === "gdapRelationshipAge"
+ ? "gdapAge"
+ : "unknown",
+ },
+ operator: {
+ label:
+ rule.operator === "eq"
+ ? "Equals"
+ : rule.operator === "ne"
+ ? "Not Equals"
+ : rule.operator === "in"
+ ? "In"
+ : rule.operator === "notIn"
+ ? "Not In"
+ : rule.operator === "like"
+ ? "Contains"
+ : rule.operator === "notlike"
+ ? "Does Not Contain"
+ : rule.operator === "gt"
+ ? "Greater Than"
+ : rule.operator === "ge"
+ ? "Greater Than or Equal"
+ : rule.operator === "lt"
+ ? "Less Than"
+ : rule.operator === "le"
+ ? "Less Than or Equal"
+ : rule.operator,
+ value: rule.operator,
+ },
+ value: valueForForm,
+ };
+ });
+ } catch (e) {
+ console.error("Error parsing dynamic rules:", e, groupData.DynamicRules);
+ formattedDynamicRules = [{}];
+ }
+ }
+
+ formControl.reset({
+ groupId: id,
+ groupName: groupData?.Name ?? "",
+ groupDescription: groupData?.Description ?? "",
+ groupType: isDynamic ? "dynamic" : "static",
+ ruleLogic: groupData?.RuleLogic || "and",
+ excludePartnerTenant: groupData?.ExcludePartnerTenant ?? false,
+ members: !isDynamic
+ ? groupData?.Members?.map((member) => ({
+ label: member.displayName,
+ value: member.customerId,
+ })) || []
+ : [],
+ dynamicRules: formattedDynamicRules,
+ });
+ }
+ }, [groupDetails.isSuccess, groupDetails.data, id]);
+
+ const customDataFormatter = (values) => {
+ const formattedData = {
+ ...values,
+ Action: "AddEdit",
+ };
+
+ // If it's a dynamic group, format the rules for the backend
+ if (values.groupType === "dynamic" && values.dynamicRules) {
+ formattedData.dynamicRules = values.dynamicRules.map((rule) => ({
+ property: rule.property?.value || rule.property,
+ operator: rule.operator?.value || rule.operator,
+ value: rule.value,
+ }));
+ formattedData.ruleLogic = values.ruleLogic || "and";
+ }
+
+ return formattedData;
+ };
+
+ return (
+
+
+
+
+
+ );
+};
+
+Page.getLayout = (page) => {page};
+
+export default Page;
diff --git a/src/pages/tenant/administration/tenants/groups/index.js b/src/pages/tenant/administration/tenants/groups/index.js
deleted file mode 100644
index 5095155edfef..000000000000
--- a/src/pages/tenant/administration/tenants/groups/index.js
+++ /dev/null
@@ -1,108 +0,0 @@
-import { Layout as DashboardLayout } from "../../../../../layouts/index.js";
-import { TabbedLayout } from "../../../../../layouts/TabbedLayout";
-import { CippTablePage } from "../../../../../components/CippComponents/CippTablePage.jsx";
-import tabOptions from "../tabOptions";
-import { Edit, PlayArrow, GroupAdd, ViewList } from "@mui/icons-material";
-import { TrashIcon } from "@heroicons/react/24/outline";
-import { CippAddTenantGroupDrawer } from "../../../../../components/CippComponents/CippAddTenantGroupDrawer";
-import { CippApiLogsDrawer } from "../../../../../components/CippComponents/CippApiLogsDrawer";
-import { CippTenantGroupOffCanvas } from "../../../../../components/CippComponents/CippTenantGroupOffCanvas";
-import { CippApiDialog } from "../../../../../components/CippComponents/CippApiDialog.jsx";
-import { Box, Button } from "@mui/material";
-import { useDialog } from "../../../../../hooks/use-dialog.js";
-import { useState } from "react"
-
-const Page = () => {
- const pageTitle = "Tenant Groups";
- const createDefaultGroupsDialog = useDialog();
- const [showUsage, setShowUsage] = useState(false);
-
- const simpleColumns = showUsage
- ? ["Name", "Description", "GroupType", "Members", "Usage"]
- : ["Name", "Description", "GroupType", "Members"];
-
- const offcanvas = {
- children: (row) => {
- return ;
- },
- size: "xl",
- };
- const actions = [
- {
- label: "Edit Group",
- link: "/tenant/administration/tenants/groups/edit?id=[Id]",
- icon: ,
- },
- {
- label: "Run Dynamic Rules",
- icon: ,
- url: "/api/ExecRunTenantGroupRule",
- type: "POST",
- data: { groupId: "Id" },
- queryKey: "TenantGroupListPage",
- confirmText: "Are you sure you want to run dynamic rules for [Name]?",
- condition: (row) => row.GroupType === "dynamic",
- },
- {
- label: "Delete Group",
- icon: ,
- url: "/api/ExecTenantGroup",
- type: "POST",
- data: { action: "Delete", groupId: "Id" },
- queryKey: "TenantGroupListPage",
- confirmText: "Are you sure you want to delete [Name]?",
- },
- ];
-
- return (
- <>
-
-
- setShowUsage(!showUsage)} startIcon={}>
- {showUsage ? "Hide Usage" : "Show Usage"}
-
- }>
- Create Default Groups
-
-
-
- }
- offCanvas={offcanvas}
- />
-
- >
- );
-};
-
-Page.getLayout = (page) => (
-
- {page}
-
-);
-
-export default Page;
diff --git a/src/pages/tenant/administration/tenants/groups/index.jsx b/src/pages/tenant/administration/tenants/groups/index.jsx
new file mode 100644
index 000000000000..852fa75938a0
--- /dev/null
+++ b/src/pages/tenant/administration/tenants/groups/index.jsx
@@ -0,0 +1,108 @@
+import { Layout as DashboardLayout } from "../../../../../layouts/index";
+import { CippIcons } from "../../../../../utils/icon-registry"
+import { TabbedLayout } from "../../../../../layouts/TabbedLayout";
+import { CippTablePage } from "../../../../../components/CippComponents/CippTablePage.jsx";
+import tabOptions from "../tabOptions";
+import { CippAddTenantGroupDrawer } from "../../../../../components/CippComponents/CippAddTenantGroupDrawer";
+import { CippApiLogsDrawer } from "../../../../../components/CippComponents/CippApiLogsDrawer";
+import { CippTenantGroupOffCanvas } from "../../../../../components/CippComponents/CippTenantGroupOffCanvas";
+import { CippApiDialog } from "../../../../../components/CippComponents/CippApiDialog.jsx";
+import { Box, Button } from "@mui/material";
+import { useDialog } from "../../../../../hooks/use-dialog.js";
+import { useState } from "react"
+
+const Page = () => {
+ const pageTitle = "Tenant Groups";
+ const createDefaultGroupsDialog = useDialog();
+ const [showUsage, setShowUsage] = useState(false);
+
+ const simpleColumns = showUsage
+ ? ["Name", "Description", "GroupType", "Members", "Usage"]
+ : ["Name", "Description", "GroupType", "Members"];
+
+ const offcanvas = {
+ children: (row) => {
+ return ;
+ },
+ size: "xl",
+ };
+ const actions = [
+ {
+ label: "Edit Group",
+ link: "/tenant/administration/tenants/groups/edit?id=[Id]",
+ pinned: true,
+ icon: ,
+ },
+ {
+ label: "Run Dynamic Rules",
+ icon: ,
+ url: "/api/ExecRunTenantGroupRule",
+ type: "POST",
+ data: { groupId: "Id" },
+ queryKey: "TenantGroupListPage",
+ confirmText: "Are you sure you want to run dynamic rules for [Name]?",
+ condition: (row) => row.GroupType === "dynamic",
+ },
+ {
+ label: "Delete Group",
+ icon: ,
+ url: "/api/ExecTenantGroup",
+ type: "POST",
+ data: { action: "Delete", groupId: "Id" },
+ queryKey: "TenantGroupListPage",
+ confirmText: "Are you sure you want to delete [Name]?",
+ },
+ ];
+
+ return (
+ <>
+
+
+ setShowUsage(!showUsage)} startIcon={}>
+ {showUsage ? "Hide Usage" : "Show Usage"}
+
+ }>
+ Create Default Groups
+
+
+
+ }
+ offCanvas={offcanvas}
+ />
+
+ >
+ );
+};
+
+Page.getLayout = (page) => (
+
+ {page}
+
+);
+
+export default Page;
diff --git a/src/pages/tenant/administration/tenants/index.js b/src/pages/tenant/administration/tenants/index.js
deleted file mode 100644
index 9eb110355cd6..000000000000
--- a/src/pages/tenant/administration/tenants/index.js
+++ /dev/null
@@ -1,71 +0,0 @@
-import { Layout as DashboardLayout } from "../../../../layouts/index.js";
-import { TabbedLayout } from "../../../../layouts/TabbedLayout";
-import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx";
-import { DeleteOutline, Edit } from "@mui/icons-material";
-import tabOptions from "./tabOptions";
-
-const Page = () => {
- const pageTitle = "Tenants";
-
- const simpleColumns = [
- "displayName",
- "defaultDomainName",
- "tenantGroups",
- "portal_m365",
- "portal_exchange",
- "portal_entra",
- "portal_sharepoint",
- "portal_teams",
- "portal_azure",
- "portal_intune",
- "portal_security",
- "portal_compliance",
- "portal_platform",
- "portal_bi",
- ];
-
- const actions = [
- {
- label: "Edit Tenant",
- link: "/tenant/manage/edit?tenantFilter=[defaultDomainName]",
- icon: ,
- },
- {
- label: "Configure Backup",
- link: "/tenant/manage/configuration-backup?tenantFilter=[defaultDomainName]",
- icon: ,
- },
- {
- label: "Delete Capabilities Cache",
- type: "GET",
- url: "/api/RemoveTenantCapabilitiesCache",
- data: { defaultDomainName: "defaultDomainName" },
- confirmText: "Are you sure you want to delete the capabilities cache for this tenant?",
- color: "info",
- icon: ,
- },
- ];
-
- return (
-
- );
-};
-
-Page.getLayout = (page) => (
-
- {page}
-
-);
-
-export default Page;
diff --git a/src/pages/tenant/administration/tenants/index.jsx b/src/pages/tenant/administration/tenants/index.jsx
new file mode 100644
index 000000000000..a3f904140df2
--- /dev/null
+++ b/src/pages/tenant/administration/tenants/index.jsx
@@ -0,0 +1,72 @@
+import { Layout as DashboardLayout } from "../../../../layouts/index";
+import { TabbedLayout } from "../../../../layouts/TabbedLayout";
+import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx";
+import { CippIcons } from "../../../../utils/icon-registry"
+import tabOptions from "./tabOptions";
+
+const Page = () => {
+ const pageTitle = "Tenants";
+
+ const simpleColumns = [
+ "displayName",
+ "defaultDomainName",
+ "tenantGroups",
+ "portal_m365",
+ "portal_exchange",
+ "portal_entra",
+ "portal_sharepoint",
+ "portal_teams",
+ "portal_azure",
+ "portal_intune",
+ "portal_security",
+ "portal_compliance",
+ "portal_platform",
+ "portal_bi",
+ ];
+
+ const actions = [
+ {
+ label: "Edit Tenant",
+ link: "/tenant/manage/edit?tenantFilter=[defaultDomainName]",
+ pinned: true,
+ icon: ,
+ },
+ {
+ label: "Configure Backup",
+ link: "/tenant/manage/configuration-backup?tenantFilter=[defaultDomainName]",
+ icon: ,
+ },
+ {
+ label: "Delete Capabilities Cache",
+ type: "POST",
+ url: "/api/RemoveTenantCapabilitiesCache",
+ data: { defaultDomainName: "defaultDomainName" },
+ confirmText: "Are you sure you want to delete the capabilities cache for this tenant?",
+ color: "info",
+ icon: ,
+ },
+ ];
+
+ return (
+
+ );
+};
+
+Page.getLayout = (page) => (
+
+ {page}
+
+);
+
+export default Page;
diff --git a/src/pages/tenant/backup/backup-wizard/add.jsx b/src/pages/tenant/backup/backup-wizard/add.jsx
index d4efe84bcbda..c2e737089d3c 100644
--- a/src/pages/tenant/backup/backup-wizard/add.jsx
+++ b/src/pages/tenant/backup/backup-wizard/add.jsx
@@ -3,7 +3,7 @@ import { Typography } from "@mui/material";
import { Grid } from "@mui/system";
import { useForm } from "react-hook-form";
import { omit } from "lodash";
-import { Layout as DashboardLayout } from "../../../../layouts/index.js";
+import { Layout as DashboardLayout } from "../../../../layouts/index";
import CippFormPage from "../../../../components/CippFormPages/CippFormPage";
import CippFormComponent from "../../../../components/CippComponents/CippFormComponent";
import { useSettings } from "../../../../hooks/use-settings";
diff --git a/src/pages/tenant/backup/backup-wizard/index.js b/src/pages/tenant/backup/backup-wizard/index.js
deleted file mode 100644
index 59b1f67b6c25..000000000000
--- a/src/pages/tenant/backup/backup-wizard/index.js
+++ /dev/null
@@ -1,65 +0,0 @@
-import { Layout as DashboardLayout } from "../../../../layouts/index.js";
-import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx";
-import { Button } from "@mui/material";
-import { Restore, Backup } from "@mui/icons-material";
-import Link from "next/link";
-
-const Page = () => {
- return (
-
- }
- >
- Restore Configuration Backup
-
- }>
- Add Configuration Backup Task
-
- >
- }
- tenantInTitle={false}
- apiData={{
- showHidden: true,
- Type: "New-CIPPBackup",
- }}
- simpleColumns={[
- "Tenant",
- "Name",
- "Parameters.ScheduledBackupValues",
- "TaskState",
- "ExecutedTime",
- ]}
- actions={[
- {
- label: "Delete Task",
- type: "POST",
- url: "/api/RemoveScheduledItem",
- data: { ID: "RowKey" },
- confirmText: "Do you want to delete this job?",
- },
- ]}
- offCanvas={{
- extendedInfoFields: ["Name", "Tenant", "TaskState", "ExecutedTime"],
- actions: [
- {
- label: "Delete Task",
- type: "POST",
- url: "/api/RemoveScheduledItem",
- data: { ID: "RowKey" },
- confirmText: "Do you want to delete this job?",
- },
- ],
- }}
- />
- );
-};
-
-Page.getLayout = (page) => {page};
-
-export default Page;
diff --git a/src/pages/tenant/backup/backup-wizard/index.jsx b/src/pages/tenant/backup/backup-wizard/index.jsx
new file mode 100644
index 000000000000..4529921dc888
--- /dev/null
+++ b/src/pages/tenant/backup/backup-wizard/index.jsx
@@ -0,0 +1,66 @@
+import { Layout as DashboardLayout } from "../../../../layouts/index";
+import { CippIcons } from "../../../../utils/icon-registry";
+import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx";
+import { Button } from "@mui/material";
+import Link from "next/link";
+
+const Page = () => {
+ return (
+
+ }
+ >
+ Restore Configuration Backup
+
+ }>
+ Add Configuration Backup Task
+
+ >
+ }
+ tenantInTitle={false}
+ apiData={{
+ showHidden: true,
+ Type: "New-CIPPBackup",
+ }}
+ simpleColumns={[
+ "Tenant",
+ "Name",
+ "Parameters.ScheduledBackupValues",
+ "TaskState",
+ "ExecutedTime",
+ ]}
+ actions={[
+ {
+ label: "Delete Task",
+ type: "POST",
+ url: "/api/RemoveScheduledItem",
+ data: { ID: "RowKey" },
+ confirmText: "Do you want to delete this job?",
+ },
+ ]}
+ offCanvas={{
+ extendedInfoFields: ["Name", "Tenant", "TaskState", "ExecutedTime"],
+ actions: [
+ {
+ label: "Delete Task",
+ type: "POST",
+ url: "/api/RemoveScheduledItem",
+ data: { ID: "RowKey" },
+ confirmText: "Do you want to delete this job?",
+ },
+ ],
+ }}
+ />
+ );
+};
+
+Page.getLayout = (page) => {page};
+
+export default Page;
+
diff --git a/src/pages/tenant/backup/backup-wizard/restore.jsx b/src/pages/tenant/backup/backup-wizard/restore.jsx
index 12a628ddc16f..a5b34761d447 100644
--- a/src/pages/tenant/backup/backup-wizard/restore.jsx
+++ b/src/pages/tenant/backup/backup-wizard/restore.jsx
@@ -2,7 +2,7 @@ import { useState, useEffect } from "react";
import { Alert, Divider, Typography } from "@mui/material";
import { Grid } from "@mui/system";
import { useForm } from "react-hook-form";
-import { Layout as DashboardLayout } from "../../../../layouts/index.js";
+import { Layout as DashboardLayout } from "../../../../layouts/index";
import CippFormPage from "../../../../components/CippFormPages/CippFormPage";
import CippFormComponent from "../../../../components/CippComponents/CippFormComponent";
import { useSettings } from "../../../../hooks/use-settings";
diff --git a/src/pages/tenant/baselines/alignment/index.js b/src/pages/tenant/baselines/alignment/index.js
deleted file mode 100644
index 7f643e3bef01..000000000000
--- a/src/pages/tenant/baselines/alignment/index.js
+++ /dev/null
@@ -1,3130 +0,0 @@
-import {
- Alert,
- Box,
- Button,
- Card,
- CardContent,
- Chip,
- CircularProgress,
- Container,
- Divider,
- Link,
- Stack,
- TextField,
- ToggleButton,
- ToggleButtonGroup,
- Tooltip,
- Typography,
-} from '@mui/material'
-import {
- Timeline,
- TimelineConnector,
- TimelineContent,
- TimelineDot,
- TimelineItem,
- TimelineOppositeContent,
- TimelineSeparator,
-} from '@mui/lab'
-import { Grid } from '@mui/system'
-import { useState } from 'react'
-import { useRouter } from 'next/router'
-import {
- BuildingOfficeIcon,
- CheckBadgeIcon,
- ClockIcon,
- ExclamationTriangleIcon,
- KeyIcon,
- RectangleStackIcon,
- ShieldCheckIcon,
- Squares2X2Icon,
-} from '@heroicons/react/24/outline'
-import {
- ArrowForward,
- BuildOutlined,
- Cancel,
- CheckCircle,
- CheckCircleOutlined,
- Compare,
- Edit,
- ErrorOutlineOutlined,
- InfoOutlined,
- LayersClear,
- PlayArrow,
- RemoveCircle,
- Search,
- TaskAlt,
- Tune,
- Visibility,
- WarningAmberOutlined,
-} from '@mui/icons-material'
-import { Layout as DashboardLayout } from '../../../../layouts/index.js'
-import { TabbedLayout } from '../../../../layouts/TabbedLayout'
-import tabOptions from '../tabOptions.json'
-import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx'
-import { CippDataTable } from '../../../../components/CippTable/CippDataTable'
-import { CippQueueTracker } from '../../../../components/CippTable/CippQueueTracker'
-import { CippHead } from '../../../../components/CippComponents/CippHead'
-import { CippInfoBar } from '../../../../components/CippCards/CippInfoBar'
-import { CippChartCard } from '../../../../components/CippCards/CippChartCard'
-import CippButtonCard from '../../../../components/CippCards/CippButtonCard'
-import { CippApiDialog } from '../../../../components/CippComponents/CippApiDialog'
-import { CippApiLogsDrawer } from '../../../../components/CippComponents/CippApiLogsDrawer'
-import CippFormComponent from '../../../../components/CippComponents/CippFormComponent'
-import { CippFormTemplateTenantSelector } from '../../../../components/CippComponents/CippFormTemplateTenantSelector'
-import CippBaselineWhatIfReport, {
- describeStageConditions,
-} from '../../../../components/CippBaselines/CippBaselineWhatIfReport'
-import CippBaselineStandardSettings, {
- variableValuesFromExpected,
-} from '../../../../components/CippBaselines/CippBaselineStandardSettings'
-import { useDialog } from '../../../../hooks/use-dialog'
-import { useSettings } from '../../../../hooks/use-settings'
-import { ApiGetCall } from '../../../../api/ApiCall'
-import { parseCippDate } from '../../../../utils/parse-cipp-date'
-import { CippOffCanvas } from '../../../../components/CippComponents/CippOffCanvas'
-import { CippAutoComplete } from '../../../../components/CippComponents/CippAutocomplete'
-import CippJsonView from '../../../../components/CippFormPages/CippJSONView'
-
-const deviationColors = {
- Compliant: 'success',
- Accepted: 'info',
- 'Partially Accepted': 'warning',
- Drift: 'error',
- Conflict: 'error',
- 'Denied - Remediate Pending': 'warning',
- 'Denied - Delete Pending': 'warning',
- 'Skipped - No License': 'default',
- 'No Data': 'default',
-}
-
-// Identity-carrying tiers (CA/Intune templates): the tier configures a full policy
-// template, so the card shows a View Policy button that opens the template in the
-// same policy viewer the editor's picker preview uses - a raw variables blob means
-// nothing to an operator.
-const templatePolicySources = {
- intuneTemplate: {
- title: 'Intune Template',
- url: '/api/ListIntuneTemplates',
- queryKey: 'ListIntuneTemplates',
- property: 'RAWJson',
- type: 'intune',
- },
- caTemplate: {
- title: 'Conditional Access Policy',
- url: '/api/ListCATemplates',
- queryKey: 'ListCATemplates',
- type: 'default',
- },
-}
-
-const TierPolicyView = ({ variableKey, templateRef }) => {
- const [visible, setVisible] = useState(false)
- const source = templatePolicySources[variableKey]
- const templatesApi = ApiGetCall({
- url: source.url,
- queryKey: source.queryKey,
- waiting: visible,
- })
- const rawRef =
- templateRef && typeof templateRef === 'object'
- ? templateRef.value
- : templateRef
- const entry = (templatesApi.data ?? []).find(
- (template) => template.GUID === rawRef
- )
- let policy = entry ?? null
- if (entry && source.property) {
- try {
- policy = JSON.parse(entry[source.property])
- } catch {
- policy = entry
- }
- }
- return (
- <>
- }
- onClick={() => setVisible(true)}
- >
- View Policy
-
- setVisible(false)}
- title={source.title}
- size="xl"
- >
- {templatesApi.isFetching ? (
-
- ) : policy ? (
-
- ) : (
-
- The template could not be found - it may have been deleted from the
- template library.
-
- )}
-
- >
- )
-}
-
-// Detect-drift standards flag LIVE policies that no baseline covers. Their cards get a
-// View Policy button that pulls the real policy from the tenant on demand, so an
-// operator can read what it actually does before accepting or deleting it.
-const detectPolicySources = {
- DetectIntuneDrift: {
- title: 'Intune Policy',
- url: '/api/ListIntunePolicy',
- queryKey: 'ListIntunePolicy',
- type: 'intune',
- },
- DetectConditionalAccessDrift: {
- title: 'Conditional Access Policy',
- url: '/api/ListConditionalAccessPolicies',
- queryKey: 'ListConditionalAccessPolicies',
- dataKey: 'Results',
- type: 'default',
- },
-}
-
-const LivePolicyView = ({ standardName, tenantFilter, policyId }) => {
- const [visible, setVisible] = useState(false)
- const source = detectPolicySources[standardName]
- const policiesApi = ApiGetCall({
- url: source.url,
- data: { tenantFilter },
- queryKey: `${source.queryKey}-${tenantFilter}`,
- waiting: visible,
- })
- const policies = source.dataKey
- ? (policiesApi.data?.[source.dataKey] ?? [])
- : (policiesApi.data ?? [])
- const policy = policies.find((entry) => entry?.id === policyId)
- return (
- <>
- }
- onClick={() => setVisible(true)}
- >
- View Policy
-
- setVisible(false)}
- title={source.title}
- size="xl"
- >
- {policiesApi.isFetching ? (
-
- ) : policy ? (
-
- ) : (
-
- The policy could not be found - it may already have been removed
- from the tenant.
-
- )}
-
- >
- )
-}
-
-const propertyList = (properties) => (
- }
- sx={{ borderBottom: '1px solid', borderColor: 'divider' }}
- >
- {properties.map(({ label, value, color }) => (
-
-
- {label}
-
- {color ? (
-
- ) : (
-
- {value ?? 'N/A'}
-
- )}
-
- ))}
-
-)
-
-// The API serializes single-element arrays as a bare object; the selector needs a real array.
-const asOptionArray = (value) =>
- (Array.isArray(value) ? value : value ? [value] : []).filter(
- (entry) => entry && typeof entry === 'object' && entry.value
- )
-
-const runModeLabels = {
- run: 'Full run',
- compare: 'Compare',
- oneoff: 'One-off remediation',
- triage: 'Operator action',
- stage: 'Stage change',
- delete: 'Deletion',
-}
-
-// Timeline dot/chip styling per run outcome, mirroring the manage-tenant history page.
-const outcomeTimeline = {
- Compliant: {
- color: 'success',
- chipColor: 'success',
- icon: ,
- },
- Remediated: { color: 'info', chipColor: 'info', icon: },
- Drift: {
- color: 'warning',
- chipColor: 'error',
- icon: ,
- },
- Error: { color: 'error', chipColor: 'error', icon: },
- 'Skipped-NoCache': {
- color: 'grey',
- chipColor: 'default',
- icon: ,
- label: 'Skipped - No Data',
- },
- 'Skipped-License': {
- color: 'grey',
- chipColor: 'default',
- icon: ,
- label: 'Skipped - No License',
- },
- // Operator/system audit events (triage verdicts, overrides, stage changes,
- // deletions carried out for denied deviations).
- Accepted: { color: 'info', chipColor: 'info', icon: },
- 'Property Accepted': { color: 'info', chipColor: 'info', icon: },
- 'Denied - Remediation Ordered': {
- color: 'warning',
- chipColor: 'warning',
- icon: ,
- },
- 'Denied - Delete Ordered': {
- color: 'warning',
- chipColor: 'warning',
- icon: ,
- },
- 'Property Denied': {
- color: 'warning',
- chipColor: 'warning',
- icon: ,
- },
- 'Triage Cleared': { color: 'grey', chipColor: 'default', icon: },
- 'Property Triage Cleared': {
- color: 'grey',
- chipColor: 'default',
- icon: ,
- },
- 'Task Completed': {
- color: 'success',
- chipColor: 'success',
- icon: ,
- },
- 'Override Created': { color: 'info', chipColor: 'info', icon: },
- 'Override Removed': {
- color: 'grey',
- chipColor: 'default',
- icon: ,
- },
- 'Stage Advanced': {
- color: 'primary',
- chipColor: 'primary',
- icon: ,
- },
- Deleted: { color: 'error', chipColor: 'error', icon: },
- 'Delete Failed': {
- color: 'error',
- chipColor: 'error',
- icon: ,
- },
-}
-
-// One readable sentence per run event for the historic timeline. Operator and
-// system events carry their own story in `detail`; run events derive one here.
-const historyEventMessage = (event) => {
- if (event.detail) {
- return `"${event.standardLabel}" - ${event.detail}`
- }
- switch (event.outcome) {
- case 'Remediated':
- return `Successfully changed "${event.standardLabel}" to the expected configuration`
- case 'Compliant':
- return `Verified "${event.standardLabel}" is compliant with the baseline`
- case 'Drift':
- return `Detected drift on "${event.standardLabel}"`
- case 'Error':
- return `Failed to change "${event.standardLabel}" - see the logs for this run`
- case 'Skipped-License':
- return `Skipped "${event.standardLabel}" - the tenant is not licensed for it`
- case 'Skipped-NoCache':
- return `Skipped "${event.standardLabel}" - no data collected yet`
- default:
- return `"${event.standardLabel}" - ${event.outcome}`
- }
-}
-
-// A run's diff can be long - hide it behind a toggle so the history list stays readable.
-const RunDetails = ({ diff }) => {
- const [open, setOpen] = useState(false)
- if (!diff) return null
- const entries = Array.isArray(diff) ? diff : [diff]
- return (
- <>
- setOpen((prev) => !prev)}
- >
- {open ? 'Hide run details' : 'View details of this run'}
-
- {open &&
- entries.map((entry, index) => (
-
- {entry?.Property}: expected {JSON.stringify(entry?.ExpectedValue)},
- found {JSON.stringify(entry?.ReceivedValue)}
-
- ))}
- >
- )
-}
-
-const jsonBox = (value, isCompliant) => (
-
-
- {JSON.stringify(value, null, 2)}
-
-
-)
-
-const Page = () => {
- const pageTitle = 'Baseline Alignment'
- const router = useRouter()
- const currentTenant = useSettings().currentTenant
- const [viewMode, setViewMode] = useState('tenant')
- const [advanceTarget, setAdvanceTarget] = useState(null)
- const advanceDialog = useDialog()
- const [triageTarget, setTriageTarget] = useState(null)
- const triageDialog = useDialog()
- const [overrideTarget, setOverrideTarget] = useState(null)
- const overrideDialog = useDialog()
- const [acceptPathTarget, setAcceptPathTarget] = useState(null)
- const acceptPathDialog = useDialog()
- const [denyPathTarget, setDenyPathTarget] = useState(null)
- const denyPathDialog = useDialog()
- const [removeOverrideTarget, setRemoveOverrideTarget] = useState(null)
- const removeOverrideDialog = useDialog()
- // Filtering re-orders the timeline, so expansion state keys on stable event/run
- // identity rather than render index.
- const [expandedEvents, setExpandedEvents] = useState(new Set())
- const toggleEventExpansion = (eventKey) => {
- setExpandedEvents((prev) => {
- const next = new Set(prev)
- if (next.has(eventKey)) {
- next.delete(eventKey)
- } else {
- next.add(eventKey)
- }
- return next
- })
- }
- const [expandedRuns, setExpandedRuns] = useState(new Set())
- const toggleRunExpansion = (runKey) => {
- setExpandedRuns((prev) => {
- const next = new Set(prev)
- if (next.has(runKey)) {
- next.delete(runKey)
- } else {
- next.add(runKey)
- }
- return next
- })
- }
- const [historyFilters, setHistoryFilters] = useState({
- standard: [],
- outcome: [],
- mode: [],
- search: '',
- })
- const [historyLimit, setHistoryLimit] = useState(50)
- const setHistoryFilter = (name, value) => {
- setHistoryFilters((prev) => ({ ...prev, [name]: value }))
- setHistoryLimit(50)
- }
- const isTenantView = viewMode === 'tenant'
- const isTemplateView = viewMode === 'template'
-
- // Refetch everything baseline-related after any triage/run/override action:
- // the wildcard invalidates every ListBaseline* query, including the '-table'
- // keys the table instances register. The queue key re-discovers the run a
- // Compare/Remediate/Run action just started, so the progress tracker appears.
- const relatedQueryKeys = ['ListBaseline*', 'ListCippQueue-BaselineRun']
-
- // Live run progress: baseline runs tag their queue entry with this reference;
- // the newest one drives the tracker chip next to the view toggle.
- const baselineQueues = ApiGetCall({
- url: '/api/ListCippQueue',
- data: { Reference: 'BaselineRun' },
- queryKey: 'ListCippQueue-BaselineRun',
- })
- const latestBaselineQueueId = Array.isArray(baselineQueues.data)
- ? baselineQueues.data[0]?.RowKey
- : baselineQueues.data?.RowKey
-
- // Deep link support: /tenant/baselines/alignment?status=Drift lands with the
- // table pre-filtered (the Fleet Overview tiles link here).
- const initialStatusFilter = router.query.status
- ? [{ id: 'status', value: router.query.status }]
- : []
-
- const resolvedApi = ApiGetCall({
- url: '/api/ListBaselineAlignment',
- data: { tenantFilter: currentTenant },
- queryKey: `ListBaselineAlignment-${currentTenant}`,
- waiting: isTenantView && !!currentTenant,
- })
- const aggregateApi = ApiGetCall({
- url: '/api/ListBaselineAlignment',
- data: { byStandard: true },
- queryKey: 'ListBaselineAlignment-byStandard',
- waiting: viewMode === 'standard',
- })
- const historyApi = ApiGetCall({
- url: '/api/ListBaselineAlignment',
- data: { tenantFilter: currentTenant, history: true },
- queryKey: `ListBaselineAlignment-${currentTenant}-history`,
- waiting: viewMode === 'history' && !!currentTenant,
- })
- const baselinesApi = ApiGetCall({
- url: '/api/ListBaselines',
- queryKey: 'ListBaselines',
- })
- const definitionsApi = ApiGetCall({
- url: '/api/ListBaselineStandards',
- queryKey: 'ListBaselineStandards',
- })
-
- const catalog = definitionsApi.data ?? []
- // A per-path deny queues an OBJECT deletion, so it only exists where the
- // definition ships a delete executor (the detect-drift standards, where each
- // path IS a policy). Ordinary standards get accept-only per-property actions -
- // enforcing the baseline is the row-level Deny.
- const supportsPathDeletion = (standardName) =>
- !!catalog.find((entry) => entry.name === `${standardName}`.split('#')[0])
- ?.delete
- const baselines = baselinesApi.data ?? []
- const standardAggregates = aggregateApi.data?.standards ?? []
- const tenant = {
- displayName: currentTenant,
- tenantFilter: currentTenant,
- tenantId: currentTenant,
- total: 0,
- applicable: 0,
- licenseMissing: 0,
- compliant: 0,
- accepted: 0,
- drift: 0,
- denied: 0,
- verifiedPercentage: 0,
- alignedPercentage: 0,
- acceptedPercentage: 0,
- ...(resolvedApi.data?.summary ?? {}),
- rows: resolvedApi.data?.rows ?? [],
- }
- const stageStates = resolvedApi.data?.stageStates ?? []
-
- const triageFormFields = ({ formHook }) => (
-
-
-
-
-
- )
-
- const tenantActions = [
- {
- label: 'Compare Now',
- type: 'POST',
- url: '/api/ExecBaselineRun',
- icon: ,
- color: 'info',
- data: {
- mode: '!compare',
- tenantFilter: 'tenantFilter',
- standard: 'standardName',
- },
- confirmText:
- 'Run a compare-only pass of [standardLabel] against [tenantFilter]? No changes will be made.',
- multiPost: false,
- relatedQueryKeys,
- // Compare applies to manual tasks too: it re-evaluates the completion recurrence,
- // flipping a task back to Drift once its reopen window has elapsed. A Conflict
- // cannot even compare - the expected value itself is ambiguous.
- condition: (row) => row.status !== 'Conflict',
- bulkFilterEligible: true,
- },
- {
- label: 'Remediate Now',
- type: 'POST',
- url: '/api/ExecBaselineRun',
- icon: ,
- color: 'success',
- data: {
- mode: '!oneoff',
- tenantFilter: 'tenantFilter',
- standard: 'standardName',
- },
- confirmText:
- 'Fix [standardLabel] on [tenantFilter] now? CIPP immediately applies the configured expected value.',
- multiPost: false,
- relatedQueryKeys,
- // Running remediation by hand is always possible - the engine deploys the expected
- // value regardless of the current state, and a license bought after the last run
- // should not block trying. Manual tasks have nothing to deploy; a Conflict has no
- // unambiguous expected value to deploy.
- condition: (row) =>
- !row.standardName.startsWith('ManualTask') && row.status !== 'Conflict',
- hideCondition: (row) => row.standardName.startsWith('ManualTask'),
- bulkFilterEligible: true,
- },
- {
- label: 'Accept Deviation',
- type: 'POST',
- url: '/api/ExecUpdateBaselineDeviation',
- icon: ,
- color: 'info',
- data: {
- action: '!Accept',
- tenantFilter: 'tenantFilter',
- standard: 'standardName',
- },
- children: triageFormFields,
- confirmText:
- 'Accept the current deviation on [standardLabel]? The tenant counts as aligned, and alerts are silenced until the acceptance expires.',
- multiPost: false,
- relatedQueryKeys,
- // Manual tasks are completed, not triaged.
- condition: (row) =>
- ['Drift', 'Partially Accepted'].includes(row.status) &&
- !row.standardName.startsWith('ManualTask'),
- hideCondition: (row) => row.standardName.startsWith('ManualTask'),
- bulkFilterEligible: true,
- },
- {
- label: 'Deny & Fix Deviation',
- type: 'POST',
- url: '/api/ExecUpdateBaselineDeviation',
- icon: ,
- color: 'warning',
- data: {
- action: '!Deny',
- method: '!remediate',
- tenantFilter: 'tenantFilter',
- standard: 'standardName',
- },
- children: ({ formHook }) => (
-
-
-
- ),
- confirmText:
- 'Deny the deviation on [standardLabel]? CIPP fixes it back to the baseline on the next run (within 12 hours), regardless of the configured posture.',
- multiPost: false,
- relatedQueryKeys,
- condition: (row) =>
- ['Drift', 'Partially Accepted'].includes(row.status) &&
- !row.standardName.startsWith('ManualTask'),
- hideCondition: (row) => row.standardName.startsWith('ManualTask'),
- bulkFilterEligible: true,
- },
- {
- label: 'Undo Accept/Deny',
- type: 'POST',
- url: '/api/ExecUpdateBaselineDeviation',
- icon: ,
- color: 'error',
- data: {
- action: '!Clear',
- tenantFilter: 'tenantFilter',
- standard: 'standardName',
- },
- confirmText:
- 'Clear the Accept/Deny status and any accepted properties on [standardLabel]? The deviation re-surfaces as Drift on the next run.',
- multiPost: false,
- relatedQueryKeys,
- // Also offered when only sub-object/property acceptances exist - clearing is the
- // one way to delete those.
- condition: (row) =>
- (['Accepted', 'Partially Accepted'].includes(row.status) ||
- row.status?.startsWith('Denied') ||
- Object.keys(row.acceptedPaths ?? {}).length > 0) &&
- !row.standardName.startsWith('ManualTask'),
- hideCondition: (row) => row.standardName.startsWith('ManualTask'),
- bulkFilterEligible: true,
- },
- {
- label: 'Mark Task Complete',
- type: 'POST',
- url: '/api/ExecUpdateBaselineDeviation',
- icon: ,
- color: 'success',
- data: {
- action: '!CompleteTask',
- tenantFilter: 'tenantFilter',
- standard: 'standardName',
- },
- confirmText:
- 'Mark the manual task [standardLabel] as completed for [tenantFilter]? A new deviation is raised again on the configured recurrence.',
- multiPost: false,
- relatedQueryKeys,
- // Instance keys are 'ManualTask#n' - an exact match missed every instance but the first.
- condition: (row) =>
- row.standardName.startsWith('ManualTask') && row.status === 'Drift',
- hideCondition: (row) => !row.standardName.startsWith('ManualTask'),
- bulkFilterEligible: true,
- },
- {
- label: 'Create Tenant Override',
- type: 'POST',
- url: '/api/ExecBaselineOverride',
- icon: ,
- color: 'info',
- // Overrides configure ONE tenant's settings in a dialog - meaningless as a bulk action.
- hideBulk: true,
- data: {
- action: '!createOverride',
- tenantFilter: 'tenantFilter',
- standard: 'standardName',
- },
- children: ({ formHook, row }) => {
- const standard = catalog.find(
- (entry) => entry.name === row.standardName
- )
- if (!standard) return null
- return (
-
-
- The settings below are pre-filled with what {row.sourceTemplate}{' '}
- currently applies to this tenant. Saving creates a tenant-specific
- override that replaces them.
-
-
-
- )
- },
- confirmText:
- 'Create a tenant-specific override of [standardLabel] for [tenantFilter]?',
- multiPost: false,
- relatedQueryKeys,
- condition: (row) => {
- const standard = catalog.find(
- (entry) => entry.name === row.standardName
- )
- // An existing override is removed, not re-created.
- return (
- row.sourceTemplate !== 'Tenant Override' &&
- Object.keys(standard?.variables ?? {}).length > 0
- )
- },
- hideCondition: (row) => row.standardName.startsWith('ManualTask'),
- },
- {
- label: 'Remove Tenant Override',
- type: 'POST',
- url: '/api/ExecBaselineOverride',
- icon: ,
- color: 'error',
- hideBulk: true,
- data: {
- action: '!deleteOverride',
- tenantFilter: 'tenantFilter',
- standard: 'standardName',
- },
- confirmText:
- 'Remove the tenant override on [standardLabel] for [tenantFilter]? The tenant falls back to the configuration inherited from the wider baseline on the next run.',
- multiPost: false,
- relatedQueryKeys,
- condition: (row) => row.sourceTemplate === 'Tenant Override',
- hideCondition: (row) => row.standardName.startsWith('ManualTask'),
- },
- ]
-
- const standardActions = [
- {
- label: 'Deploy To All Tenants',
- type: 'POST',
- url: '/api/ExecBaselineRun',
- icon: ,
- color: 'success',
- data: {
- mode: '!oneoff',
- tenantFilter: '!AllTenants',
- standard: 'standardName',
- },
- confirmText:
- 'Deploy [standardLabel] to every applicable tenant from its configured expected value? Accepted and suppressed deviations are left untouched.',
- multiPost: false,
- relatedQueryKeys,
- // Manual tasks have nothing to deploy - operators complete them instead.
- hideCondition: (row) => row.standardName.startsWith('ManualTask'),
- },
- {
- label: 'Mark Task Complete (All Tenants)',
- type: 'POST',
- url: '/api/ExecUpdateBaselineDeviation',
- icon: ,
- color: 'success',
- data: {
- action: '!CompleteTask',
- tenantFilter: '!AllTenants',
- standard: 'standardName',
- },
- confirmText:
- 'Mark the manual task [standardLabel] as completed for every applicable tenant? Each tenant raises it again on the configured recurrence.',
- multiPost: false,
- relatedQueryKeys,
- hideCondition: (row) => !row.standardName.startsWith('ManualTask'),
- },
- {
- label: 'Compare All Tenants',
- type: 'POST',
- url: '/api/ExecBaselineRun',
- icon: ,
- color: 'info',
- data: {
- mode: '!compare',
- tenantFilter: '!AllTenants',
- standard: 'standardName',
- },
- confirmText:
- 'Run a compare-only pass of [standardLabel] on every tenant? No changes will be made.',
- multiPost: false,
- relatedQueryKeys,
- },
- {
- label: 'Edit Baseline',
- link: '/tenant/baselines/template?id=[templateId]',
- icon: ,
- color: 'success',
- target: '_self',
- },
- ]
-
- const tenantOffCanvas = {
- size: 'md',
- title: 'Standard Details',
- contentPadding: 0,
- children: (row) => {
- // The offcanvas renders with an empty row until one is selected. Rows without
- // collected data (No Data) have nothing to diff against.
- // Per-property drift comes from the ENGINE's persisted diff - the frontend never
- // re-derives compares, so $anyOf/hard-compare/acceptance semantics live in exactly
- // one place. A diff Property may be a nested dot-path under a card's path.
- const diffEntries = Array.isArray(row.diff)
- ? row.diff
- : row.diff
- ? [row.diff]
- : []
- const hasDiffAt = (path) =>
- diffEntries.some(
- (entry) =>
- entry?.Property === path || entry?.Property?.startsWith(`${path}.`)
- )
- // Display flattening only (never comparison): big policies like CA render each
- // sub-object as its own card (conditions.users, conditions.applications, ...)
- // instead of one unreadable JSON blob. Empty-vs-empty cards are skipped unless
- // the engine flagged drift there.
- // A literal property name wins over dot-path traversal: policy names routinely
- // contain dots ("... - v3.0"), and splitting those would resolve to nothing.
- const getPath = (source, path) => {
- if (
- source &&
- typeof source === 'object' &&
- Object.prototype.hasOwnProperty.call(source, path)
- ) {
- return source[path]
- }
- return path
- .split('.')
- .reduce((acc, key) => (acc == null ? acc : acc[key]), source)
- }
- const isPlainObject = (value) =>
- value && typeof value === 'object' && !Array.isArray(value)
- const isEmptyish = (value) =>
- value == null ||
- (Array.isArray(value) && value.length === 0) ||
- (isPlainObject(value) && Object.keys(value).length === 0)
- // Expand every sub-object down to its leaves (scalars/arrays), so acceptance is
- // exactly one setting: accepting conditions.users.excludeUsers never tolerates a
- // change to includeUsers. Empty-vs-empty leaves are hidden below, keeping the
- // card list compact despite the depth.
- const buildCardPaths = (value, prefix = '') =>
- Object.keys(value ?? {}).flatMap((key) => {
- const child = value[key]
- const path = prefix ? `${prefix}.${key}` : key
- return isPlainObject(child) ? buildCardPaths(child, path) : [path]
- })
- const cardPaths = buildCardPaths(row.expectedValue).filter(
- (path) =>
- hasDiffAt(path) ||
- !(
- isEmptyish(getPath(row.expectedValue, path)) &&
- isEmptyish(getPath(row.currentValue, path))
- )
- )
- const differences = cardPaths.filter(hasDiffAt)
- // Drift first: the whole point of opening the offcanvas is seeing what's wrong -
- // deviating cards render before compliant ones (stable within each group).
- const orderedCardPaths = [...cardPaths].sort(
- (a, b) => Number(hasDiffAt(b)) - Number(hasDiffAt(a))
- )
- // Settings-catalog diffs key on friendly setting LABELS, not object paths - any
- // diff entry that maps to no expected-value path renders as its own card, valued
- // straight from the engine's diff.
- const unmatchedDiffEntries = diffEntries.filter(
- (entry) =>
- entry?.Property &&
- !cardPaths.some(
- (path) =>
- entry.Property === path || entry.Property.startsWith(`${path}.`)
- )
- )
- const properties = [
- { label: 'Standard', value: row.standardLabel },
- {
- label: 'State',
- value: row.status,
- color: deviationColors[row.status],
- },
- { label: 'Impact', value: row.impact },
- { label: 'Stage', value: row.stage },
- { label: 'Configured By', value: row.sourceTemplate },
- {
- label: 'Last Run',
- value: row.lastRun
- ? parseCippDate(row.lastRun).toLocaleString()
- : 'N/A',
- },
- ]
- if (row.deviationReason) {
- properties.push({
- label: 'Deviation Reason',
- value: row.deviationReason,
- })
- properties.push({ label: 'Set By', value: row.deviationBy })
- properties.push({
- label: 'Expires',
- value: row.deviationExpires
- ? parseCippDate(row.deviationExpires).toLocaleDateString()
- : 'Never',
- })
- }
- if (row.pendingVerification) {
- properties.push({
- label: 'Verification',
- value: 'Remediated - awaiting next run',
- color: 'info',
- })
- }
-
- return (
-
- {propertyList(properties)}
-
- {row.status === 'Conflict' && (
-
- Two baselines configure this standard at the same assignment
- level with different settings, so CIPP cannot know which one is
- intended - nothing is compared or fixed until you edit one of
- the baselines below.
-
- )}
-
- Effective Configuration
-
- {(row.inheritance ?? []).map((tier) => (
-
-
-
-
- {tier.templateName}
-
-
- Assigned to: {tier.assignedTo}
-
-
- {tier.effective && (
-
- )}
-
- {tier.remediateEnabled !== undefined && (
-
-
-
-
-
-
-
- {tier.alertOnRemediate && (
-
-
-
- )}
-
- )}
- {tier.value?.intuneTemplate || tier.value?.caTemplate ? (
-
-
-
- ) : (
-
- {JSON.stringify(tier.value)}
-
- )}
- {tier.effective && tier.templateName === 'Tenant Override' && (
- }
- sx={{ mt: 1 }}
- onClick={() => {
- setRemoveOverrideTarget(row)
- removeOverrideDialog.handleOpen()
- }}
- >
- Remove Override
-
- )}
-
- ))}
-
- When multiple baselines configure the same standard, the baseline
- with the most specific assignment wins.
-
-
- Expected vs Current
-
- {row.currentValue ? (
- <>
- {unmatchedDiffEntries.map((entry) => {
- const acceptedPath = row.acceptedPaths?.[entry.Property]
- // Detect-drift cards reference a real policy in the tenant: show what
- // it is in plain language and offer to open it, instead of a blob.
- const policyRef =
- detectPolicySources[row.standardName] &&
- entry.ReceivedValue?.id
- ? entry.ReceivedValue
- : null
- return (
-
-
-
- {entry.Property}
-
- {acceptedPath ? (
-
-
-
- ) : (
-
- )}
-
- {policyRef ? (
-
- {policyRef.status}
- {policyRef.policyType
- ? ` - ${policyRef.policyType}`
- : ''}
- {policyRef.state ? ` - ${policyRef.state}` : ''}
-
- ) : (
- <>
-
- Expected: {JSON.stringify(entry.ExpectedValue)}
-
-
- Current: {JSON.stringify(entry.ReceivedValue)}
-
- >
- )}
-
- {policyRef && (
-
- )}
- {!acceptedPath && (
- }
- onClick={() => {
- setAcceptPathTarget({
- ...row,
- path: entry.Property,
- })
- acceptPathDialog.handleOpen()
- }}
- >
- Accept this property only
-
- )}
- {!acceptedPath &&
- supportsPathDeletion(row.standardName) && (
- }
- onClick={() => {
- setDenyPathTarget({
- ...row,
- path: entry.Property,
- })
- denyPathDialog.handleOpen()
- }}
- >
- Deny & queue deletion
-
- )}
-
-
- )
- })}
- {orderedCardPaths.map((key) => {
- const drifted = differences.includes(key)
- const acceptedPath = row.acceptedPaths?.[key]
- // Detect-drift cards reference a real policy in the tenant: show what
- // it is in plain language and offer to open it, instead of a blob.
- const cardCurrent = getPath(row.currentValue, key)
- const policyRef =
- detectPolicySources[row.standardName] && cardCurrent?.id
- ? cardCurrent
- : null
- return (
-
-
-
- {key}
-
- {acceptedPath ? (
-
-
-
- ) : drifted ? (
-
- ) : (
-
- )}
-
- {policyRef ? (
-
- {policyRef.status}
- {policyRef.policyType
- ? ` - ${policyRef.policyType}`
- : ''}
- {policyRef.state ? ` - ${policyRef.state}` : ''}
-
- ) : (
- <>
-
- Expected:{' '}
- {JSON.stringify(getPath(row.expectedValue, key))}
-
-
- Current: {JSON.stringify(cardCurrent)}
-
- >
- )}
-
- {policyRef && (
-
- )}
- {drifted && !acceptedPath && (
- }
- onClick={() => {
- setAcceptPathTarget({ ...row, path: key })
- acceptPathDialog.handleOpen()
- }}
- >
- Accept this property only
-
- )}
- {drifted &&
- !acceptedPath &&
- supportsPathDeletion(row.standardName) && (
- }
- onClick={() => {
- setDenyPathTarget({ ...row, path: key })
- denyPathDialog.handleOpen()
- }}
- >
- Deny & queue deletion
-
- )}
-
-
- )
- })}
- {(differences.length > 0 ||
- unmatchedDiffEntries.length > 0) && (
-
- Accepting a single property tolerates only that value -
- drift on any other property still raises a deviation.
-
- )}
- >
- ) : (
- <>
- {jsonBox(row.expectedValue, true)}
-
- No data has been collected for this standard yet - this is the
- configuration that will apply.
-
- >
- )}
- {(row.manual?.taskName || row.manual?.instructions) && (
- <>
-
- Manual Task
-
-
- {row.manual.taskName && (
-
- {row.manual.taskName}
-
- )}
- {row.manual.instructions && (
-
- {row.manual.instructions}
-
- )}
- {row.manual.documentationUrl && (
-
- Open documentation
-
- )}
- {row.manual.reopen && row.manual.reopen !== 'once' && (
-
- Reopens {row.manual.reopen} after completion.
-
- )}
-
- >
- )}
-
- Last Runs
-
- {(row.history ?? []).map((run) => (
-
-
-
- {parseCippDate(run.timestamp).toLocaleString()}
-
-
-
-
- {runModeLabels[run.mode] ?? run.mode}, triggered by{' '}
- {run.triggeredBy}
- {run.remediated ? ', remediated' : ''}
-
- {run.detail && (
-
- {run.detail}
-
- )}
-
-
- ))}
- }
- sx={{ alignSelf: 'flex-start' }}
- onClick={() => {
- setHistoryFilters({
- standard: row.standardLabel ? [row.standardLabel] : [],
- outcome: [],
- mode: [],
- search: '',
- })
- setHistoryLimit(50)
- setViewMode('history')
- }}
- >
- View full history
-
-
-
- )
- },
- }
-
- const standardOffCanvas = {
- size: 'md',
- title: 'Standard Tenant Summary',
- contentPadding: 0,
- children: (row) => (
-
- {propertyList([
- { label: 'Standard', value: row.standardLabel },
- { label: 'Category', value: row.category },
- { label: 'Impact', value: row.impact },
- {
- label: 'Compliant with accepted deviations',
- value: `${row.alignedPercentage}%`,
- },
- {
- label: 'Compliant with baseline',
- value: `${row.verifiedPercentage}%`,
- },
- { label: 'Accepted Deviations', value: row.accepted },
- { label: 'License Missing', value: row.licenseMissing },
- {
- label: 'Secure Score Impact',
- value: row.secureScoreImpact
- ? `+${row.secureScoreImpact} points`
- : 'None',
- },
- ])}
- {/* A single point is just today's live score (already listed above) - the
- chart earns its space once there is an actual line to draw. */}
- {Array.isArray(row.trend) && row.trend.length > 1 && (
-
- ({
- x: point.date,
- y: point.aligned,
- })),
- },
- {
- name: 'Compliant with baseline',
- data: row.trend.map((point) => ({
- x: point.date,
- y: point.verified,
- })),
- },
- ]}
- />
-
- )}
-
-
- Tenant States
-
- {/* The offcanvas renders with an empty row until one is selected. */}
- {(row.rows ?? []).map((tenantRow) => (
-
-
-
-
- {tenantRow.tenantName}
-
-
- {tenantRow.tenantFilter}
-
-
-
-
- {tenantRow.deviationReason && (
-
- {tenantRow.deviationReason}
-
- )}
- {([
- 'Drift',
- 'Partially Accepted',
- 'Denied - Remediate Pending',
- ].includes(tenantRow.status) ||
- tenantRow.sourceTemplate === 'Tenant Override') && (
-
- {[
- 'Drift',
- 'Partially Accepted',
- 'Denied - Remediate Pending',
- ].includes(tenantRow.status) && (
- <>
- }
- onClick={() => {
- setTriageTarget(tenantRow)
- triageDialog.handleOpen()
- }}
- >
- Accept Deviation
-
- {tenantRow.sourceTemplate !== 'Tenant Override' && (
- }
- onClick={() => {
- setOverrideTarget(tenantRow)
- overrideDialog.handleOpen()
- }}
- >
- Tenant Override
-
- )}
- >
- )}
- {tenantRow.sourceTemplate === 'Tenant Override' && (
- }
- onClick={() => {
- setRemoveOverrideTarget(tenantRow)
- removeOverrideDialog.handleOpen()
- }}
- >
- Remove Override
-
- )}
-
- )}
-
- ))}
-
-
- ),
- }
-
- const templateActions = [
- {
- label: 'Edit Baseline',
- link: '/tenant/baselines/template?id=[GUID]',
- icon: ,
- color: 'success',
- target: '_self',
- },
- {
- label: 'Run Baseline Now',
- type: 'POST',
- url: '/api/ExecBaselineRun',
- icon: ,
- color: 'info',
- data: { mode: '!run', templateId: 'GUID' },
- children: ({ formHook, row }) => (
-
-
-
- ),
- confirmText:
- 'Run [templateName] now? Pick a single covered tenant, or All Tenants in Template for the whole assignment. Standards in report-only stages are compared without changes.',
- multiPost: false,
- relatedQueryKeys,
- },
- ]
-
- const templateOffCanvas = {
- size: 'md',
- title: 'Baseline Rollout',
- contentPadding: 0,
- children: (row) => {
- // The offcanvas renders with an empty row until one is selected.
- const tenantStates = row.tenantStates ?? []
- return (
-
- {propertyList([
- { label: 'Baseline', value: row.templateName },
- { label: 'Description', value: row.description },
- { label: 'Standards', value: row.standardsCount },
- { label: 'Stages', value: (row.stageNames ?? []).join(' -> ') },
- {
- label: 'Assigned To',
- value: (row.assignedTenants ?? []).join(', '),
- },
- { label: 'Remediation', value: row.remediationPosture },
- ])}
-
-
- Tenant Stage Progress
-
- {tenantStates.length === 0 && (
-
- No tenants are currently tracked in this rollout.
-
- )}
- {tenantStates.map((state) => (
-
-
-
-
- {state.tenantName}
-
-
- Entered{' '}
- {parseCippDate(state.enteredStageAt).toLocaleDateString()}
-
-
-
-
- {state.manualAdvance && (
- }
- onClick={() => {
- setAdvanceTarget({
- tenantFilter: state.tenantFilter,
- templateId: row.GUID,
- templateName: row.templateName,
- nextStageName: state.nextStageName,
- })
- advanceDialog.handleOpen()
- }}
- >
- Move to Next Stage
-
- )}
-
-
- {state.nextStage ? (
-
- Next: Stage {state.currentStage + 1} ({state.nextStageName})
- - advances when {describeStageConditions(state.nextStage)}
- {state.estimatedAdvanceAt
- ? `, estimated ${parseCippDate(state.estimatedAdvanceAt).toLocaleDateString()}`
- : ''}
-
- ) : (
-
- Final stage - the full template is applied.
-
- )}
-
- ))}
-
-
- )
- },
- }
-
- const tenantFilterList = [
- {
- filterName: 'Open Deviations',
- value: [{ id: 'status', value: 'Drift' }],
- type: 'column',
- },
- {
- filterName: 'Accepted',
- value: [{ id: 'status', value: 'Accepted' }],
- type: 'column',
- },
- {
- filterName: 'Denied',
- value: [{ id: 'status', value: 'Denied - Remediate Pending' }],
- type: 'column',
- },
- {
- filterName: 'License Missing',
- value: [{ id: 'status', value: 'Skipped - No License' }],
- type: 'column',
- },
- ]
-
- const standardFilterList = [
- {
- filterName: 'Has Open Deviations',
- value: [{ id: 'drift', value: 1 }],
- type: 'column',
- },
- {
- filterName: 'Has Accepted Deviations',
- value: [{ id: 'accepted', value: 1 }],
- type: 'column',
- },
- {
- filterName: 'Has License Missing',
- value: [{ id: 'licenseMissing', value: 1 }],
- type: 'column',
- },
- ]
-
- // Page-level view selector, shown above the score bar and table.
- const modeToggle = (
- {
- if (newViewMode !== null) setViewMode(newViewMode)
- }}
- sx={{
- '& .MuiToggleButton-root': { py: 0.5, px: 1.5, fontSize: '0.8125rem' },
- }}
- >
-
-
-
-
- Tenant View
-
-
-
-
-
-
-
- Standard View
-
-
-
-
-
-
-
- Baseline View
-
-
-
-
-
-
-
- Historic View
-
-
-
-
- )
-
- const rolloutCard = (
-
- {stageStates.length === 0 && (
-
- No baselines are assigned to this tenant.
-
- )}
-
- {[...stageStates]
- .sort((a, b) =>
- String(a.templateName).localeCompare(String(b.templateName))
- )
- .map((state) => (
-
-
-
-
- {state.templateName}
-
-
- Entered{' '}
- {parseCippDate(state.enteredStageAt).toLocaleDateString()}
-
-
-
- {state.alignedPercentage !== null && (
-
-
-
- )}
-
- {!state.nextStage && (
-
- )}
- {state.manualAdvance && (
-
-
-
- )}
-
- {state.nextStage && (
-
- Next: Stage {state.currentStage + 1} ({state.nextStageName})
- - advances when {describeStageConditions(state.nextStage)}
-
- )}
- {state.manualAdvance && (
- <>
-
-
-
- }
- onClick={() => {
- setAdvanceTarget({
- tenantFilter: tenant.tenantId,
- templateId: state.templateId,
- templateName: state.templateName,
- nextStageName: state.nextStageName,
- })
- advanceDialog.handleOpen()
- }}
- >
- Move to next stage ({state.nextStageName})
-
-
- >
- )}
-
-
- ))}
-
-
- )
-
- const tenantScoreBar = (
- ,
- name: 'Compliant with accepted deviations',
- data: `${tenant.alignedPercentage}%`,
- color: 'success',
- toolTip: `${tenant.acceptedPercentage}% of this score comes from accepted deviations`,
- },
- {
- icon: ,
- name: 'Compliant with baseline',
- data: `${tenant.verifiedPercentage}%`,
- },
- {
- icon: ,
- name: 'Open Deviations',
- data: tenant.drift,
- color: 'error',
- },
- {
- icon: ,
- name: 'License Missing',
- data: `${tenant.total ? Math.round((tenant.licenseMissing / tenant.total) * 100) : 0}%`,
- color: 'warning',
- toolTip: `${tenant.licenseMissing} standard${tenant.licenseMissing === 1 ? '' : 's'} excluded from scoring because the tenant lacks the license`,
- },
- ]}
- />
- )
-
- const overrideStandard = overrideTarget
- ? catalog.find((entry) => entry.name === overrideTarget.standardName)
- : null
-
- // The triage/override/advance dialogs are shared between the tenant layout and the
- // table page for the other views.
- const dialogs = (
- <>
- {advanceTarget && (
-
- )}
- {triageTarget && (
-
- )}
- {overrideTarget && overrideStandard && (
- (
-
-
- The settings below are pre-filled with what{' '}
- {overrideTarget.sourceTemplate} currently applies to{' '}
- {overrideTarget.tenantName}. Saving creates a tenant-specific
- override that replaces them.
-
-
-
- )}
- api={{
- url: '/api/ExecBaselineOverride',
- type: 'POST',
- data: {
- action: '!createOverride',
- tenantFilter: 'tenantFilter',
- standard: 'standardName',
- },
- confirmText:
- 'Create a tenant-specific override of [standardLabel] for [tenantFilter]?',
- relatedQueryKeys,
- }}
- row={overrideTarget}
- />
- )}
- {acceptPathTarget && (
-
- )}
- {denyPathTarget && (
-
- )}
- {removeOverrideTarget && (
-
- )}
- >
- )
-
- // Historic view: every recorded baseline event for the tenant on an activity
- // timeline (same pattern as the manage-tenant history page). Engine runs touch
- // many standards under one run GUID, so those group into a collapsible summary
- // entry; operator events (triage, overrides, stage changes, deletions) stand on
- // their own. View Logs opens the Baselines log drawer filtered to one run.
- if (viewMode === 'history') {
- const historyEvents = historyApi.data?.events ?? []
- const standardOptions = [
- ...new Set(historyEvents.map((event) => event.standardLabel)),
- ]
- .filter(Boolean)
- .sort()
- .map((value) => ({ label: value, value }))
- const outcomeOptions = [
- ...new Set(historyEvents.map((event) => event.outcome)),
- ]
- .filter(Boolean)
- .sort()
- .map((value) => ({
- label: outcomeTimeline[value]?.label ?? value,
- value,
- }))
- const modeOptions = [...new Set(historyEvents.map((event) => event.mode))]
- .filter(Boolean)
- .map((value) => ({ label: runModeLabels[value] ?? value, value }))
- const searchTerm = historyFilters.search.trim().toLowerCase()
- const filteredEvents = historyEvents.filter(
- (event) =>
- (historyFilters.standard.length === 0 ||
- historyFilters.standard.includes(event.standardLabel)) &&
- (historyFilters.outcome.length === 0 ||
- historyFilters.outcome.includes(event.outcome)) &&
- (historyFilters.mode.length === 0 ||
- historyFilters.mode.includes(event.mode)) &&
- (!searchTerm ||
- `${event.standardLabel} ${event.outcome} ${event.detail ?? ''} ${event.triggeredBy}`
- .toLowerCase()
- .includes(searchTerm))
- )
- // Group by run GUID (newest-first order preserved); multi-event groups render
- // as one collapsible summary. Flattening to render rows up front lets the
- // timeline connector stop at the true last item.
- const runGroups = []
- const groupIndex = new Map()
- for (const event of filteredEvents) {
- const key = String(event.runId ?? 'unknown')
- if (groupIndex.has(key)) {
- runGroups[groupIndex.get(key)].events.push(event)
- } else {
- groupIndex.set(key, runGroups.length)
- runGroups.push({ runId: key, events: [event] })
- }
- }
- const visibleGroups = runGroups.slice(0, historyLimit)
- const renderRows = []
- for (const group of visibleGroups) {
- if (group.events.length === 1) {
- renderRows.push({ type: 'event', event: group.events[0] })
- } else {
- renderRows.push({ type: 'group', group })
- if (expandedRuns.has(group.runId)) {
- for (const event of group.events) {
- renderRows.push({ type: 'event', event })
- }
- }
- }
- }
- return (
- <>
-
-
-
-
- {modeToggle}
-
-
-
- This timeline shows every recorded baseline event for{' '}
- {tenant.displayName} - runs, operator decisions, stage changes,
- and deletions.
-
-
-
-
- setHistoryFilter('search', event.target.value)
- }
- autoComplete="off"
- placeholder="Search by standard, outcome, or operator..."
- InputProps={{
- startAdornment: (
-
- ),
- }}
- />
-
-
- ({
- label: value,
- value,
- }))}
- onChange={(newValue) =>
- setHistoryFilter(
- 'standard',
- Array.isArray(newValue)
- ? newValue.map((option) => option.value)
- : []
- )
- }
- />
-
-
- ({
- label: outcomeTimeline[value]?.label ?? value,
- value,
- }))}
- onChange={(newValue) =>
- setHistoryFilter(
- 'outcome',
- Array.isArray(newValue)
- ? newValue.map((option) => option.value)
- : []
- )
- }
- />
-
-
- ({
- label: runModeLabels[value] ?? value,
- value,
- }))}
- onChange={(newValue) =>
- setHistoryFilter(
- 'mode',
- Array.isArray(newValue)
- ? newValue.map((option) => option.value)
- : []
- )
- }
- />
-
-
- {historyApi.isFetching && (
-
-
-
- )}
- {!historyApi.isFetching && historyEvents.length === 0 && (
-
- No baseline run history for this tenant yet - run a baseline
- first.
-
- )}
- {!historyApi.isFetching &&
- historyEvents.length > 0 &&
- filteredEvents.length === 0 && (
-
- No events match the current filters.
-
- )}
- {renderRows.length > 0 && (
-
-
-
- {renderRows.map((row, index) => {
- // Collapsed engine run: one summary entry with per-outcome
- // counts; expanding reveals the individual standards below.
- if (row.type === 'group') {
- const group = row.group
- const first = group.events[0]
- const groupDate = parseCippDate(first.timestamp)
- const outcomeCounts = {}
- for (const groupEvent of group.events) {
- outcomeCounts[groupEvent.outcome] =
- (outcomeCounts[groupEvent.outcome] ?? 0) + 1
- }
- const severityRank = {
- error: 4,
- warning: 3,
- info: 2,
- success: 1,
- }
- const dotColor = group.events.reduce(
- (worst, groupEvent) => {
- const color =
- outcomeTimeline[groupEvent.outcome]?.color ??
- 'grey'
- return (severityRank[color] ?? 0) >
- (severityRank[worst] ?? 0)
- ? color
- : worst
- },
- 'grey'
- )
- const isOpen = expandedRuns.has(group.runId)
- const alertedCount = group.events.filter(
- (groupEvent) => groupEvent.alerted
- ).length
- return (
-
-
-
- {groupDate.toLocaleDateString('en-US', {
- month: 'short',
- day: 'numeric',
- year: 'numeric',
- })}
-
-
- {groupDate.toLocaleTimeString('en-US', {
- hour: '2-digit',
- minute: '2-digit',
- hour12: false,
- })}
-
-
-
-
- {first.mode === 'compare' ? (
-
- ) : (
-
- )}
-
- {index < renderRows.length - 1 && (
-
- )}
-
-
-
-
-
-
-
-
- {Object.entries(outcomeCounts).map(
- ([outcome, count]) => (
-
- )
- )}
- {alertedCount > 0 && (
-
- )}
-
-
- Processed {group.events.length} standards in
- this run
-
-
-
- toggleRunExpansion(group.runId)
- }
- sx={{
- textAlign: 'left',
- fontSize: '0.75rem',
- }}
- >
- {isOpen
- ? 'Hide the individual standards'
- : `View all ${group.events.length} standards`}
-
-
-
-
- Triggered by {first.triggeredBy}
-
-
-
-
- )
- }
- const event = row.event
- const timelineConfig = outcomeTimeline[event.outcome] ?? {
- color: 'grey',
- chipColor: 'default',
- icon: ,
- }
- const eventDate = parseCippDate(event.timestamp)
- const eventKey = `${event.runId}-${event.standardName}-${event.outcome}-${event.timestamp}`
- const isExpanded = expandedEvents.has(eventKey)
- const diffEntries = event.diff
- ? Array.isArray(event.diff)
- ? event.diff
- : [event.diff]
- : []
- return (
-
-
-
- {eventDate.toLocaleDateString('en-US', {
- month: 'short',
- day: 'numeric',
- year: 'numeric',
- })}
-
-
- {eventDate.toLocaleTimeString('en-US', {
- hour: '2-digit',
- minute: '2-digit',
- hour12: false,
- })}
-
-
-
-
- {timelineConfig.icon}
-
- {index < renderRows.length - 1 && (
-
- )}
-
-
-
-
-
-
-
-
-
- {event.alerted && (
-
- )}
-
-
-
- {historyEventMessage(event)}
-
-
-
- toggleEventExpansion(eventKey)
- }
- sx={{
- textAlign: 'left',
- fontSize: '0.75rem',
- }}
- >
- {isExpanded
- ? 'Hide details'
- : 'View details'}
-
-
-
- {isExpanded && (
-
- {diffEntries.map((entry, diffIndex) => (
-
- {entry?.Property}: expected{' '}
- {JSON.stringify(entry?.ExpectedValue)},
- found{' '}
- {JSON.stringify(entry?.ReceivedValue)}
-
- ))}
-
- Run ID: {event.runId}
-
-
- )}
-
-
- Triggered by {event.triggeredBy}
-
-
-
-
- )
- })}
-
-
-
- )}
- {runGroups.length > historyLimit && (
- setHistoryLimit((prev) => prev + 50)}
- >
- Load more (showing {historyLimit} of {runGroups.length} entries)
-
- )}
- {dialogs}
-
-
- >
- )
- }
-
- // Tenant view: custom layout so the deviation feed sits directly next to the
- // alignment table.
- if (isTenantView) {
- return (
- <>
-
-
-
-
- {modeToggle}
-
-
-
-
-
- {tenantScoreBar}
- {rolloutCard}
-
- {dialogs}
-
-
- >
- )
- }
-
- return (
-
-
- {modeToggle}
-
- {dialogs}
-
- }
- actions={isTemplateView ? templateActions : standardActions}
- filters={isTemplateView ? undefined : standardFilterList}
- offCanvas={isTemplateView ? templateOffCanvas : standardOffCanvas}
- offCanvasOnRowClick={true}
- simpleColumns={
- isTemplateView
- ? [
- 'baselineName',
- 'standardsCount',
- 'stageNames',
- 'assignedTenants',
- 'remediationPosture',
- 'updatedAt',
- ]
- : [
- 'standardLabel',
- 'category',
- 'impact',
- 'alignedPercentage',
- 'verifiedPercentage',
- 'accepted',
- 'drift',
- 'licenseMissing',
- 'totalTenants',
- ]
- }
- queryKey={`ListBaselineAlignment-${viewMode}-table`}
- />
- )
-}
-
-Page.getLayout = (page) => (
-
- {page}
-
-)
-
-export default Page
diff --git a/src/pages/tenant/baselines/alignment/index.jsx b/src/pages/tenant/baselines/alignment/index.jsx
new file mode 100644
index 000000000000..3fd803766563
--- /dev/null
+++ b/src/pages/tenant/baselines/alignment/index.jsx
@@ -0,0 +1,3253 @@
+import {
+ Alert,
+ Box,
+ Button,
+ Card,
+ CardContent,
+ Chip,
+ CircularProgress,
+ Container,
+ Divider,
+ Link,
+ Stack,
+ TextField,
+ ToggleButton,
+ ToggleButtonGroup,
+ Tooltip,
+ Typography,
+} from '@mui/material'
+import { CippIcons } from '../../../../utils/icon-registry'
+import {
+ Timeline,
+ TimelineConnector,
+ TimelineContent,
+ TimelineDot,
+ TimelineItem,
+ TimelineOppositeContent,
+ TimelineSeparator,
+} from '@mui/lab'
+import { Grid } from '@mui/system'
+import { useState } from 'react'
+import { useRouter } from 'next/router'
+import { Layout as DashboardLayout } from '../../../../layouts/index'
+import { TabbedLayout } from '../../../../layouts/TabbedLayout'
+import tabOptions from '../tabOptions.json'
+import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx'
+import { CippDataTable } from '../../../../components/CippTable/CippDataTable'
+import { CippQueueTracker } from '../../../../components/CippTable/CippQueueTracker'
+import { CippHead } from '../../../../components/CippComponents/CippHead'
+import { CippInfoBar } from '../../../../components/CippCards/CippInfoBar'
+import { CippChartCard } from '../../../../components/CippCards/CippChartCard'
+import CippButtonCard from '../../../../components/CippCards/CippButtonCard'
+import { CippApiDialog } from '../../../../components/CippComponents/CippApiDialog'
+import { CippApiLogsDrawer } from '../../../../components/CippComponents/CippApiLogsDrawer'
+import CippFormComponent from '../../../../components/CippComponents/CippFormComponent'
+import { CippFormTemplateTenantSelector } from '../../../../components/CippComponents/CippFormTemplateTenantSelector'
+import CippBaselineWhatIfReport, {
+ describeStageConditions,
+} from '../../../../components/CippBaselines/CippBaselineWhatIfReport'
+import CippBaselineStandardSettings, {
+ variableValuesFromExpected,
+} from '../../../../components/CippBaselines/CippBaselineStandardSettings'
+import { useDialog } from '../../../../hooks/use-dialog'
+import { useSettings } from '../../../../hooks/use-settings'
+import { ApiGetCall } from '../../../../api/ApiCall'
+import { parseCippDate } from '../../../../utils/parse-cipp-date'
+import { CippOffCanvas } from '../../../../components/CippComponents/CippOffCanvas'
+import { CippAutoComplete } from '../../../../components/CippComponents/CippAutocomplete'
+import CippJsonView from '../../../../components/CippFormPages/CippJSONView'
+
+// Manual-task instance keys are 'ManualTask#n', so match by prefix. standardName can be
+// absent on malformed or partially hydrated rows - the check must never throw.
+const isManualTaskRow = (row) => Boolean(row?.standardName?.startsWith('ManualTask'))
+
+const deviationColors = {
+ Compliant: 'success',
+ Accepted: 'info',
+ 'Partially Accepted': 'warning',
+ Drift: 'error',
+ Conflict: 'error',
+ 'Denied - Remediate Pending': 'warning',
+ 'Denied - Delete Pending': 'warning',
+ 'Skipped - No License': 'default',
+ 'No Data': 'default',
+}
+
+// Identity-carrying tiers (CA/Intune templates): the tier configures a full policy
+// template, so the card shows a View Policy button that opens the template in the
+// same policy viewer the editor's picker preview uses - a raw variables blob means
+// nothing to an operator.
+const templatePolicySources = {
+ intuneTemplate: {
+ title: 'Intune Template',
+ url: '/api/ListIntuneTemplates',
+ queryKey: 'ListIntuneTemplates',
+ property: 'RAWJson',
+ type: 'intune',
+ },
+ caTemplate: {
+ title: 'Conditional Access Policy',
+ url: '/api/ListCATemplates',
+ queryKey: 'ListCATemplates',
+ type: 'default',
+ },
+}
+
+// templateIds names a different template store per standard, so these key on the
+// standard rather than the variable.
+const templateIdsPolicySources = {
+ IntuneAppTemplateDeploy: {
+ title: 'Application Template',
+ noun: 'Template',
+ url: '/api/ListAppTemplates',
+ queryKey: 'ListAppTemplates',
+ type: 'default',
+ },
+ AppDeploy: {
+ title: 'App Approval Template',
+ noun: 'Template',
+ url: '/api/ListAppApprovalTemplates',
+ queryKey: 'ListAppApprovalTemplates',
+ idField: 'TemplateId',
+ type: 'default',
+ },
+}
+
+const TierPolicyView = ({ source, templateRef }) => {
+ const [visible, setVisible] = useState(false)
+ const templatesApi = ApiGetCall({
+ url: source.url,
+ queryKey: source.queryKey,
+ waiting: visible,
+ })
+ // Multi-template deployers configure an array of refs; the classic template
+ // standards configure a single one. Refs are option objects or bare ids.
+ const rawRefs = (Array.isArray(templateRef) ? templateRef : [templateRef]).map(
+ (ref) => (ref && typeof ref === 'object' ? ref.value : ref)
+ )
+ const idField = source.idField ?? 'GUID'
+ const entries = rawRefs
+ .map((ref) =>
+ (templatesApi.data ?? []).find((template) => template?.[idField] === ref)
+ )
+ .filter(Boolean)
+ const policies = entries.map((entry) => {
+ if (source.property) {
+ try {
+ return JSON.parse(entry[source.property])
+ } catch {
+ return entry
+ }
+ }
+ return entry
+ })
+ const missingCount = rawRefs.length - entries.length
+ const noun = source.noun ?? 'Policy'
+ return (
+ <>
+ }
+ onClick={() => setVisible(true)}
+ >
+ View {rawRefs.length > 1 ? `${noun}s (${rawRefs.length})` : noun}
+
+ setVisible(false)}
+ title={source.title}
+ size="xl"
+ >
+ {templatesApi.isFetching ? (
+
+ ) : policies.length > 0 ? (
+
+ {policies.map((policy, index) => (
+
+ ))}
+ {missingCount > 0 && (
+
+ {missingCount} of the configured {noun.toLowerCase()}s could not
+ be found - possibly deleted from the template library.
+
+ )}
+
+ ) : (
+
+ The template could not be found - it may have been deleted from the
+ template library.
+
+ )}
+
+ >
+ );
+}
+
+// Detect-drift standards flag LIVE policies that no baseline covers. Their cards get a
+// View Policy button that pulls the real policy from the tenant on demand, so an
+// operator can read what it actually does before accepting or deleting it.
+const detectPolicySources = {
+ DetectIntuneDrift: {
+ title: 'Intune Policy',
+ url: '/api/ListIntunePolicy',
+ queryKey: 'ListIntunePolicy',
+ type: 'intune',
+ },
+ DetectConditionalAccessDrift: {
+ title: 'Conditional Access Policy',
+ url: '/api/ListConditionalAccessPolicies',
+ queryKey: 'ListConditionalAccessPolicies',
+ dataKey: 'Results',
+ type: 'default',
+ },
+}
+
+const LivePolicyView = ({ standardName, tenantFilter, policyId }) => {
+ const [visible, setVisible] = useState(false)
+ const source = detectPolicySources[standardName]
+ const policiesApi = ApiGetCall({
+ url: source.url,
+ data: { tenantFilter },
+ queryKey: `${source.queryKey}-${tenantFilter}`,
+ waiting: visible,
+ })
+ const policies = source.dataKey
+ ? (policiesApi.data?.[source.dataKey] ?? [])
+ : (policiesApi.data ?? [])
+ const policy = policies.find((entry) => entry?.id === policyId)
+ return (
+ <>
+ }
+ onClick={() => setVisible(true)}
+ >
+ View Policy
+
+ setVisible(false)}
+ title={source.title}
+ size="xl"
+ >
+ {policiesApi.isFetching ? (
+
+ ) : policy ? (
+
+ ) : (
+
+ The policy could not be found - it may already have been removed
+ from the tenant.
+
+ )}
+
+ >
+ );
+}
+
+const propertyList = (properties) => (
+ }
+ sx={{ borderBottom: '1px solid', borderColor: 'divider' }}
+ >
+ {properties.map(({ label, value, color }) => (
+
+
+ {label}
+
+ {color ? (
+
+ ) : (
+
+ {value ?? 'N/A'}
+
+ )}
+
+ ))}
+
+)
+
+// The API serializes single-element arrays as a bare object; the selector needs a real array.
+const asOptionArray = (value) =>
+ (Array.isArray(value) ? value : value ? [value] : []).filter(
+ (entry) => entry && typeof entry === 'object' && entry.value
+ )
+
+const runModeLabels = {
+ run: 'Full run',
+ compare: 'Compare',
+ oneoff: 'One-off remediation',
+ triage: 'Operator action',
+ stage: 'Stage change',
+ delete: 'Deletion',
+}
+
+// Timeline dot/chip styling per run outcome, mirroring the manage-tenant history page.
+const outcomeTimeline = {
+ Compliant: {
+ color: 'success',
+ chipColor: 'success',
+ icon: ,
+ },
+ Remediated: { color: 'info', chipColor: 'info', icon: },
+ Drift: {
+ color: 'warning',
+ chipColor: 'error',
+ icon: ,
+ },
+ Error: { color: 'error', chipColor: 'error', icon: },
+ 'Skipped-NoCache': {
+ color: 'grey',
+ chipColor: 'default',
+ icon: ,
+ label: 'Skipped - No Data',
+ },
+ 'Skipped-License': {
+ color: 'grey',
+ chipColor: 'default',
+ icon: ,
+ label: 'Skipped - No License',
+ },
+ // Operator/system audit events (triage verdicts, overrides, stage changes,
+ // deletions carried out for denied deviations).
+ Accepted: { color: 'info', chipColor: 'info', icon: },
+ 'Property Accepted': { color: 'info', chipColor: 'info', icon: },
+ 'Denied - Remediation Ordered': {
+ color: 'warning',
+ chipColor: 'warning',
+ icon: ,
+ },
+ 'Denied - Delete Ordered': {
+ color: 'warning',
+ chipColor: 'warning',
+ icon: ,
+ },
+ 'Property Denied': {
+ color: 'warning',
+ chipColor: 'warning',
+ icon: ,
+ },
+ 'Triage Cleared': { color: 'grey', chipColor: 'default', icon: },
+ 'Property Triage Cleared': {
+ color: 'grey',
+ chipColor: 'default',
+ icon: ,
+ },
+ 'Task Completed': {
+ color: 'success',
+ chipColor: 'success',
+ icon: ,
+ },
+ 'Override Created': { color: 'info', chipColor: 'info', icon: },
+ 'Override Removed': {
+ color: 'grey',
+ chipColor: 'default',
+ icon: ,
+ },
+ 'Stage Advanced': {
+ color: 'primary',
+ chipColor: 'primary',
+ icon: ,
+ },
+ Deleted: { color: 'error', chipColor: 'error', icon: },
+ 'Delete Failed': {
+ color: 'error',
+ chipColor: 'error',
+ icon: ,
+ },
+}
+
+// One readable sentence per run event for the historic timeline. Operator and
+// system events carry their own story in `detail`; run events derive one here.
+const historyEventMessage = (event) => {
+ if (event.detail) {
+ return `"${event.standardLabel}" - ${event.detail}`
+ }
+ switch (event.outcome) {
+ case 'Remediated':
+ return `Successfully changed "${event.standardLabel}" to the expected configuration`
+ case 'Compliant':
+ return `Verified "${event.standardLabel}" is compliant with the baseline`
+ case 'Drift':
+ return `Detected drift on "${event.standardLabel}"`
+ case 'Error':
+ return `Failed to change "${event.standardLabel}" - see the logs for this run`
+ case 'Skipped-License':
+ return `Skipped "${event.standardLabel}" - the tenant is not licensed for it`
+ case 'Skipped-NoCache':
+ return `Skipped "${event.standardLabel}" - no data collected yet`
+ default:
+ return `"${event.standardLabel}" - ${event.outcome}`
+ }
+}
+
+// A run's diff can be long - hide it behind a toggle so the history list stays readable.
+const RunDetails = ({ diff }) => {
+ const [open, setOpen] = useState(false)
+ if (!diff) return null
+ const entries = Array.isArray(diff) ? diff : [diff]
+ return (
+ <>
+ setOpen((prev) => !prev)}
+ >
+ {open ? 'Hide run details' : 'View details of this run'}
+
+ {open &&
+ entries.map((entry, index) => (
+
+ {entry?.Property}: expected {JSON.stringify(entry?.ExpectedValue)},
+ found {JSON.stringify(entry?.ReceivedValue)}
+
+ ))}
+ >
+ )
+}
+
+const jsonBox = (value, isCompliant) => (
+
+
+ {JSON.stringify(value, null, 2)}
+
+
+)
+
+const Page = () => {
+ const pageTitle = 'Baseline Alignment'
+ const router = useRouter()
+ const currentTenant = useSettings().currentTenant
+ const [viewMode, setViewMode] = useState('tenant')
+ const [advanceTarget, setAdvanceTarget] = useState(null)
+ const advanceDialog = useDialog()
+ const [triageTarget, setTriageTarget] = useState(null)
+ const triageDialog = useDialog()
+ const [overrideTarget, setOverrideTarget] = useState(null)
+ const overrideDialog = useDialog()
+ const [acceptPathTarget, setAcceptPathTarget] = useState(null)
+ const acceptPathDialog = useDialog()
+ const [denyPathTarget, setDenyPathTarget] = useState(null)
+ const denyPathDialog = useDialog()
+ const [removeOverrideTarget, setRemoveOverrideTarget] = useState(null)
+ const removeOverrideDialog = useDialog()
+ // Filtering re-orders the timeline, so expansion state keys on stable event/run
+ // identity rather than render index.
+ const [expandedEvents, setExpandedEvents] = useState(new Set())
+ const toggleEventExpansion = (eventKey) => {
+ setExpandedEvents((prev) => {
+ const next = new Set(prev)
+ if (next.has(eventKey)) {
+ next.delete(eventKey)
+ } else {
+ next.add(eventKey)
+ }
+ return next
+ })
+ }
+ const [expandedRuns, setExpandedRuns] = useState(new Set())
+ const toggleRunExpansion = (runKey) => {
+ setExpandedRuns((prev) => {
+ const next = new Set(prev)
+ if (next.has(runKey)) {
+ next.delete(runKey)
+ } else {
+ next.add(runKey)
+ }
+ return next
+ })
+ }
+ const [historyFilters, setHistoryFilters] = useState({
+ standard: [],
+ outcome: [],
+ mode: [],
+ search: '',
+ })
+ const [historyLimit, setHistoryLimit] = useState(50)
+ const setHistoryFilter = (name, value) => {
+ setHistoryFilters((prev) => ({ ...prev, [name]: value }))
+ setHistoryLimit(50)
+ }
+ const isTenantView = viewMode === 'tenant'
+ const isTemplateView = viewMode === 'template'
+
+ // Refetch everything baseline-related after any triage/run/override action:
+ // the wildcard invalidates every ListBaseline* query, including the '-table'
+ // keys the table instances register. The queue key re-discovers the run a
+ // Compare/Remediate/Run action just started, so the progress tracker appears.
+ const relatedQueryKeys = ['ListBaseline*', 'ListCippQueue-BaselineRun']
+
+ // Live run progress: baseline runs tag their queue entry with this reference;
+ // the newest one drives the tracker chip next to the view toggle.
+ const baselineQueues = ApiGetCall({
+ url: '/api/ListCippQueue',
+ data: { Reference: 'BaselineRun' },
+ queryKey: 'ListCippQueue-BaselineRun',
+ })
+ const latestBaselineQueueId = Array.isArray(baselineQueues.data)
+ ? baselineQueues.data[0]?.RowKey
+ : baselineQueues.data?.RowKey
+
+ // Deep link support: /tenant/baselines/alignment?status=Drift lands with the
+ // table pre-filtered (the Fleet Overview tiles link here).
+ const initialStatusFilter = router.query.status
+ ? [{ id: 'status', value: router.query.status }]
+ : []
+
+ const resolvedApi = ApiGetCall({
+ url: '/api/ListBaselineAlignment',
+ data: { tenantFilter: currentTenant },
+ queryKey: `ListBaselineAlignment-${currentTenant}`,
+ waiting: isTenantView && !!currentTenant,
+ })
+ const aggregateApi = ApiGetCall({
+ url: '/api/ListBaselineAlignment',
+ data: { byStandard: true },
+ queryKey: 'ListBaselineAlignment-byStandard',
+ waiting: viewMode === 'standard',
+ })
+ const historyApi = ApiGetCall({
+ url: '/api/ListBaselineAlignment',
+ data: { tenantFilter: currentTenant, history: true },
+ queryKey: `ListBaselineAlignment-${currentTenant}-history`,
+ waiting: viewMode === 'history' && !!currentTenant,
+ })
+ const baselinesApi = ApiGetCall({
+ url: '/api/ListBaselines',
+ queryKey: 'ListBaselines',
+ })
+ const definitionsApi = ApiGetCall({
+ url: '/api/ListBaselineStandards',
+ queryKey: 'ListBaselineStandards',
+ })
+
+ const catalog = definitionsApi.data ?? []
+ // A per-path deny queues an OBJECT deletion, so it only exists where the
+ // definition ships a delete executor (the detect-drift standards, where each
+ // path IS a policy). Ordinary standards get accept-only per-property actions -
+ // enforcing the baseline is the row-level Deny.
+ const supportsPathDeletion = (standardName) =>
+ !!catalog.find((entry) => entry.name === `${standardName}`.split('#')[0])
+ ?.delete
+ const baselines = baselinesApi.data ?? []
+ const standardAggregates = aggregateApi.data?.standards ?? []
+ const tenant = {
+ displayName: currentTenant,
+ tenantFilter: currentTenant,
+ tenantId: currentTenant,
+ total: 0,
+ applicable: 0,
+ licenseMissing: 0,
+ compliant: 0,
+ accepted: 0,
+ drift: 0,
+ denied: 0,
+ verifiedPercentage: 0,
+ alignedPercentage: 0,
+ acceptedPercentage: 0,
+ ...(resolvedApi.data?.summary ?? {}),
+ rows: resolvedApi.data?.rows ?? [],
+ }
+ const stageStates = resolvedApi.data?.stageStates ?? []
+
+ const triageFormFields = ({ formHook }) => (
+
+
+
+
+
+ )
+
+ const tenantActions = [
+ {
+ label: 'Compare Now',
+ type: 'POST',
+ url: '/api/ExecBaselineRun',
+ icon: ,
+ color: 'info',
+ data: {
+ mode: '!compare',
+ tenantFilter: 'tenantFilter',
+ standard: 'standardName',
+ },
+ confirmText:
+ 'Run a compare-only pass of [standardLabel] against [tenantFilter]? No changes will be made.',
+ multiPost: false,
+ relatedQueryKeys,
+ // Compare applies to manual tasks too: it re-evaluates the completion recurrence,
+ // flipping a task back to Drift once its reopen window has elapsed. A Conflict
+ // cannot even compare - the expected value itself is ambiguous.
+ condition: (row) => row.status !== 'Conflict',
+ bulkFilterEligible: true,
+ },
+ {
+ label: 'Remediate Now',
+ type: 'POST',
+ url: '/api/ExecBaselineRun',
+ icon: ,
+ color: 'success',
+ data: {
+ mode: '!oneoff',
+ tenantFilter: 'tenantFilter',
+ standard: 'standardName',
+ },
+ confirmText:
+ 'Fix [standardLabel] on [tenantFilter] now? CIPP immediately applies the configured expected value.',
+ multiPost: false,
+ relatedQueryKeys,
+ // Running remediation by hand is always possible - the engine deploys the expected
+ // value regardless of the current state, and a license bought after the last run
+ // should not block trying. Manual tasks have nothing to deploy; a Conflict has no
+ // unambiguous expected value to deploy.
+ condition: (row) => !isManualTaskRow(row) && row.status !== 'Conflict',
+ hideCondition: (row) => isManualTaskRow(row),
+ bulkFilterEligible: true,
+ },
+ {
+ label: 'Accept Deviation',
+ type: 'POST',
+ url: '/api/ExecUpdateBaselineDeviation',
+ icon: ,
+ color: 'info',
+ data: {
+ action: '!Accept',
+ tenantFilter: 'tenantFilter',
+ standard: 'standardName',
+ },
+ children: triageFormFields,
+ confirmText:
+ 'Accept the current deviation on [standardLabel]? The tenant counts as aligned, and alerts are silenced until the acceptance expires.',
+ multiPost: false,
+ relatedQueryKeys,
+ // Manual tasks are completed, not triaged.
+ condition: (row) =>
+ ['Drift', 'Partially Accepted'].includes(row.status) && !isManualTaskRow(row),
+ hideCondition: (row) => isManualTaskRow(row),
+ bulkFilterEligible: true,
+ },
+ {
+ label: 'Deny & Fix Deviation',
+ type: 'POST',
+ url: '/api/ExecUpdateBaselineDeviation',
+ icon: ,
+ color: 'warning',
+ data: {
+ action: '!Deny',
+ method: '!remediate',
+ tenantFilter: 'tenantFilter',
+ standard: 'standardName',
+ },
+ children: ({ formHook }) => (
+
+
+
+ ),
+ confirmText:
+ 'Deny the deviation on [standardLabel]? CIPP fixes it back to the baseline on the next run (within 12 hours), regardless of the configured posture.',
+ multiPost: false,
+ relatedQueryKeys,
+ condition: (row) =>
+ ['Drift', 'Partially Accepted'].includes(row.status) && !isManualTaskRow(row),
+ hideCondition: (row) => isManualTaskRow(row),
+ bulkFilterEligible: true,
+ },
+ {
+ label: 'Undo Accept/Deny',
+ type: 'POST',
+ url: '/api/ExecUpdateBaselineDeviation',
+ icon: ,
+ color: 'error',
+ data: {
+ action: '!Clear',
+ tenantFilter: 'tenantFilter',
+ standard: 'standardName',
+ },
+ confirmText:
+ 'Clear the Accept/Deny status and any accepted properties on [standardLabel]? The deviation re-surfaces as Drift on the next run.',
+ multiPost: false,
+ relatedQueryKeys,
+ // Also offered when only sub-object/property acceptances exist - clearing is the
+ // one way to delete those.
+ condition: (row) =>
+ (['Accepted', 'Partially Accepted'].includes(row.status) ||
+ row.status?.startsWith('Denied') ||
+ Object.keys(row.acceptedPaths ?? {}).length > 0) &&
+ !isManualTaskRow(row),
+ hideCondition: (row) => isManualTaskRow(row),
+ bulkFilterEligible: true,
+ },
+ {
+ label: 'Mark Task Complete',
+ type: 'POST',
+ url: '/api/ExecUpdateBaselineDeviation',
+ icon: ,
+ color: 'success',
+ data: {
+ action: '!CompleteTask',
+ tenantFilter: 'tenantFilter',
+ standard: 'standardName',
+ },
+ confirmText:
+ 'Mark the manual task [standardLabel] as completed for [tenantFilter]? A new deviation is raised again on the configured recurrence.',
+ multiPost: false,
+ relatedQueryKeys,
+ condition: (row) => isManualTaskRow(row) && row.status === 'Drift',
+ hideCondition: (row) => !isManualTaskRow(row),
+ bulkFilterEligible: true,
+ },
+ {
+ label: 'Create Tenant Override',
+ type: 'POST',
+ url: '/api/ExecBaselineOverride',
+ icon: ,
+ color: 'info',
+ // Overrides configure ONE tenant's settings in a dialog - meaningless as a bulk action.
+ hideBulk: true,
+ data: {
+ action: '!createOverride',
+ tenantFilter: 'tenantFilter',
+ standard: 'standardName',
+ },
+ children: ({ formHook, row }) => {
+ const standard = catalog.find(
+ (entry) => entry.name === row.standardName
+ )
+ if (!standard) return null
+ return (
+
+
+ The settings below are pre-filled with what {row.sourceTemplate}{' '}
+ currently applies to this tenant. Saving creates a tenant-specific
+ override that replaces them.
+
+
+
+ );
+ },
+ confirmText:
+ 'Create a tenant-specific override of [standardLabel] for [tenantFilter]?',
+ multiPost: false,
+ relatedQueryKeys,
+ condition: (row) => {
+ const standard = catalog.find(
+ (entry) => entry.name === row.standardName
+ )
+ // An existing override is removed, not re-created.
+ return (
+ row.sourceTemplate !== 'Tenant Override' &&
+ Object.keys(standard?.variables ?? {}).length > 0
+ )
+ },
+ hideCondition: (row) => isManualTaskRow(row),
+ },
+ {
+ label: 'Remove Tenant Override',
+ type: 'POST',
+ url: '/api/ExecBaselineOverride',
+ icon: ,
+ color: 'error',
+ hideBulk: true,
+ data: {
+ action: '!deleteOverride',
+ tenantFilter: 'tenantFilter',
+ standard: 'standardName',
+ },
+ confirmText:
+ 'Remove the tenant override on [standardLabel] for [tenantFilter]? The tenant falls back to the configuration inherited from the wider baseline on the next run.',
+ multiPost: false,
+ relatedQueryKeys,
+ condition: (row) => row.sourceTemplate === 'Tenant Override',
+ hideCondition: (row) => isManualTaskRow(row),
+ },
+ ]
+
+ const standardActions = [
+ {
+ label: 'Deploy To All Tenants',
+ type: 'POST',
+ url: '/api/ExecBaselineRun',
+ icon: ,
+ color: 'success',
+ data: {
+ mode: '!oneoff',
+ tenantFilter: '!AllTenants',
+ standard: 'standardName',
+ },
+ confirmText:
+ 'Deploy [standardLabel] to every applicable tenant from its configured expected value? Accepted and suppressed deviations are left untouched.',
+ multiPost: false,
+ relatedQueryKeys,
+ // Manual tasks have nothing to deploy - operators complete them instead.
+ hideCondition: (row) => isManualTaskRow(row),
+ },
+ {
+ label: 'Mark Task Complete (All Tenants)',
+ type: 'POST',
+ url: '/api/ExecUpdateBaselineDeviation',
+ icon: ,
+ color: 'success',
+ data: {
+ action: '!CompleteTask',
+ tenantFilter: '!AllTenants',
+ standard: 'standardName',
+ },
+ confirmText:
+ 'Mark the manual task [standardLabel] as completed for every applicable tenant? Each tenant raises it again on the configured recurrence.',
+ multiPost: false,
+ relatedQueryKeys,
+ hideCondition: (row) => !isManualTaskRow(row),
+ },
+ {
+ label: 'Compare All Tenants',
+ type: 'POST',
+ url: '/api/ExecBaselineRun',
+ icon: ,
+ color: 'info',
+ data: {
+ mode: '!compare',
+ tenantFilter: '!AllTenants',
+ standard: 'standardName',
+ },
+ confirmText:
+ 'Run a compare-only pass of [standardLabel] on every tenant? No changes will be made.',
+ multiPost: false,
+ relatedQueryKeys,
+ },
+ {
+ label: 'Edit Baseline',
+ link: '/tenant/baselines/template?id=[templateId]',
+ pinned: true,
+ icon: ,
+ color: 'success',
+ target: '_self',
+ },
+ ]
+
+ const tenantOffCanvas = {
+ size: 'md',
+ title: 'Standard Details',
+ contentPadding: 0,
+ children: (row) => {
+ // The offcanvas renders with an empty row until one is selected. Rows without
+ // collected data (No Data) have nothing to diff against.
+ // Per-property drift comes from the ENGINE's persisted diff - the frontend never
+ // re-derives compares, so $anyOf/hard-compare/acceptance semantics live in exactly
+ // one place. A diff Property may be a nested dot-path under a card's path.
+ const diffEntries = Array.isArray(row.diff)
+ ? row.diff
+ : row.diff
+ ? [row.diff]
+ : []
+ const hasDiffAt = (path) =>
+ diffEntries.some(
+ (entry) =>
+ entry?.Property === path || entry?.Property?.startsWith(`${path}.`)
+ )
+ // Display flattening only (never comparison): big policies like CA render each
+ // sub-object as its own card (conditions.users, conditions.applications, ...)
+ // instead of one unreadable JSON blob. Empty-vs-empty cards are skipped unless
+ // the engine flagged drift there.
+ // A literal property name wins over dot-path traversal: policy names routinely
+ // contain dots ("... - v3.0"), and splitting those would resolve to nothing.
+ const getPath = (source, path) => {
+ if (
+ source &&
+ typeof source === 'object' &&
+ Object.prototype.hasOwnProperty.call(source, path)
+ ) {
+ return source[path]
+ }
+ return path
+ .split('.')
+ .reduce((acc, key) => (acc == null ? acc : acc[key]), source)
+ }
+ const isPlainObject = (value) =>
+ value && typeof value === 'object' && !Array.isArray(value)
+ const isEmptyish = (value) =>
+ value == null ||
+ (Array.isArray(value) && value.length === 0) ||
+ (isPlainObject(value) && Object.keys(value).length === 0)
+ // Expand every sub-object down to its leaves (scalars/arrays), so acceptance is
+ // exactly one setting: accepting conditions.users.excludeUsers never tolerates a
+ // change to includeUsers. Empty-vs-empty leaves are hidden below, keeping the
+ // card list compact despite the depth.
+ const buildCardPaths = (value, prefix = '') =>
+ Object.keys(value ?? {}).flatMap((key) => {
+ const child = value[key]
+ const path = prefix ? `${prefix}.${key}` : key
+ return isPlainObject(child) ? buildCardPaths(child, path) : [path]
+ })
+ const cardPaths = buildCardPaths(row.expectedValue).filter(
+ (path) =>
+ hasDiffAt(path) ||
+ !(
+ isEmptyish(getPath(row.expectedValue, path)) &&
+ isEmptyish(getPath(row.currentValue, path))
+ )
+ )
+ const differences = cardPaths.filter(hasDiffAt)
+ // Drift first: the whole point of opening the offcanvas is seeing what's wrong -
+ // deviating cards render before compliant ones (stable within each group).
+ const orderedCardPaths = [...cardPaths].sort(
+ (a, b) => Number(hasDiffAt(b)) - Number(hasDiffAt(a))
+ )
+ // Settings-catalog diffs key on friendly setting LABELS, not object paths - any
+ // diff entry that maps to no expected-value path renders as its own card, valued
+ // straight from the engine's diff.
+ const unmatchedDiffEntries = diffEntries.filter(
+ (entry) =>
+ entry?.Property &&
+ !cardPaths.some(
+ (path) =>
+ entry.Property === path || entry.Property.startsWith(`${path}.`)
+ )
+ )
+ const properties = [
+ { label: 'Standard', value: row.standardLabel },
+ {
+ label: 'State',
+ value: row.status,
+ color: deviationColors[row.status],
+ },
+ { label: 'Impact', value: row.impact },
+ { label: 'Stage', value: row.stage },
+ { label: 'Configured By', value: row.sourceTemplate },
+ {
+ label: 'Last Run',
+ value: row.lastRun
+ ? parseCippDate(row.lastRun).toLocaleString()
+ : 'N/A',
+ },
+ ]
+ if (row.deviationReason) {
+ properties.push({
+ label: 'Deviation Reason',
+ value: row.deviationReason,
+ })
+ properties.push({ label: 'Set By', value: row.deviationBy })
+ properties.push({
+ label: 'Expires',
+ value: row.deviationExpires
+ ? parseCippDate(row.deviationExpires).toLocaleDateString()
+ : 'Never',
+ })
+ }
+ if (row.pendingVerification) {
+ properties.push({
+ label: 'Verification',
+ value: 'Remediated - awaiting next run',
+ color: 'info',
+ })
+ }
+
+ return (
+
+ {propertyList(properties)}
+
+ {row.status === 'Conflict' && (
+
+ Two baselines configure this standard at the same assignment
+ level with different settings, so CIPP cannot know which one is
+ intended - nothing is compared or fixed until you edit one of
+ the baselines below.
+
+ )}
+
+ Effective Configuration
+
+ {(row.inheritance ?? []).map((tier) => (
+
+
+
+
+ {tier.templateName}
+
+
+ Assigned to: {tier.assignedTo}
+
+
+ {tier.effective && (
+
+ )}
+
+ {tier.remediateEnabled !== undefined && (
+
+
+
+
+
+
+
+ {tier.alertOnRemediate && (
+
+
+
+ )}
+
+ )}
+ {tier.value?.intuneTemplate || tier.value?.caTemplate ? (
+
+
+
+ ) : templateIdsPolicySources[row.standardName] &&
+ Array.isArray(tier.value?.templateIds) &&
+ tier.value.templateIds.length > 0 ? (
+
+
+
+ ) : (
+
+ {JSON.stringify(tier.value)}
+
+ )}
+ {tier.effective && tier.templateName === 'Tenant Override' && (
+ }
+ sx={{ mt: 1 }}
+ onClick={() => {
+ setRemoveOverrideTarget(row)
+ removeOverrideDialog.handleOpen()
+ }}
+ >
+ Remove Override
+
+ )}
+
+ ))}
+
+ When multiple baselines configure the same standard, the baseline
+ with the most specific assignment wins.
+
+
+ Expected vs Current
+
+ {row.currentValue ? (
+ <>
+ {unmatchedDiffEntries.map((entry) => {
+ const acceptedPath = row.acceptedPaths?.[entry.Property]
+ // Detect-drift cards reference a real policy in the tenant: show what
+ // it is in plain language and offer to open it, instead of a blob.
+ const policyRef =
+ detectPolicySources[row.standardName] &&
+ entry.ReceivedValue?.id
+ ? entry.ReceivedValue
+ : null
+ return (
+
+
+
+ {entry.Property}
+
+ {acceptedPath ? (
+
+
+
+ ) : (
+
+ )}
+
+ {policyRef ? (
+
+ {policyRef.status}
+ {policyRef.policyType
+ ? ` - ${policyRef.policyType}`
+ : ''}
+ {policyRef.state ? ` - ${policyRef.state}` : ''}
+
+ ) : (
+ <>
+
+ Expected: {JSON.stringify(entry.ExpectedValue)}
+
+
+ Current: {JSON.stringify(entry.ReceivedValue)}
+
+ >
+ )}
+
+ {policyRef && (
+
+ )}
+ {!acceptedPath && (
+ }
+ onClick={() => {
+ setAcceptPathTarget({
+ ...row,
+ path: entry.Property,
+ })
+ acceptPathDialog.handleOpen()
+ }}
+ >
+ Accept this property only
+
+ )}
+ {!acceptedPath &&
+ supportsPathDeletion(row.standardName) && (
+ }
+ onClick={() => {
+ setDenyPathTarget({
+ ...row,
+ path: entry.Property,
+ })
+ denyPathDialog.handleOpen()
+ }}
+ >
+ Deny & queue deletion
+
+ )}
+
+
+ );
+ })}
+ {orderedCardPaths.map((key) => {
+ const drifted = differences.includes(key)
+ const acceptedPath = row.acceptedPaths?.[key]
+ // Detect-drift cards reference a real policy in the tenant: show what
+ // it is in plain language and offer to open it, instead of a blob.
+ const cardCurrent = getPath(row.currentValue, key)
+ const policyRef =
+ detectPolicySources[row.standardName] && cardCurrent?.id
+ ? cardCurrent
+ : null
+ return (
+
+
+
+ {key}
+
+ {acceptedPath ? (
+
+
+
+ ) : drifted ? (
+
+ ) : (
+
+ )}
+
+ {policyRef ? (
+
+ {policyRef.status}
+ {policyRef.policyType
+ ? ` - ${policyRef.policyType}`
+ : ''}
+ {policyRef.state ? ` - ${policyRef.state}` : ''}
+
+ ) : (
+ <>
+
+ Expected:{' '}
+ {JSON.stringify(getPath(row.expectedValue, key))}
+
+
+ Current: {JSON.stringify(cardCurrent)}
+
+ >
+ )}
+
+ {policyRef && (
+
+ )}
+ {drifted && !acceptedPath && (
+ }
+ onClick={() => {
+ setAcceptPathTarget({ ...row, path: key })
+ acceptPathDialog.handleOpen()
+ }}
+ >
+ Accept this property only
+
+ )}
+ {drifted &&
+ !acceptedPath &&
+ supportsPathDeletion(row.standardName) && (
+ }
+ onClick={() => {
+ setDenyPathTarget({ ...row, path: key })
+ denyPathDialog.handleOpen()
+ }}
+ >
+ Deny & queue deletion
+
+ )}
+
+
+ );
+ })}
+ {(differences.length > 0 ||
+ unmatchedDiffEntries.length > 0) && (
+
+ Accepting a single property tolerates only that value -
+ drift on any other property still raises a deviation.
+
+ )}
+ >
+ ) : (
+ <>
+ {jsonBox(row.expectedValue, true)}
+
+ No data has been collected for this standard yet - this is the
+ configuration that will apply.
+
+ >
+ )}
+ {(row.manual?.taskName || row.manual?.instructions) && (
+ <>
+
+ Manual Task
+
+
+ {row.manual.taskName && (
+
+ {row.manual.taskName}
+
+ )}
+ {row.manual.instructions && (
+
+ {row.manual.instructions}
+
+ )}
+ {row.manual.documentationUrl && (
+
+ Open documentation
+
+ )}
+ {row.manual.reopen && row.manual.reopen !== 'once' && (
+
+ Reopens {row.manual.reopen} after completion.
+
+ )}
+
+ >
+ )}
+
+ Last Runs
+
+ {(row.history ?? []).map((run) => (
+
+
+
+ {parseCippDate(run.timestamp).toLocaleString()}
+
+
+
+
+ {runModeLabels[run.mode] ?? run.mode}, triggered by{' '}
+ {run.triggeredBy}
+ {run.remediated ? ', remediated' : ''}
+
+ {run.detail && (
+
+ {run.detail}
+
+ )}
+
+
+ ))}
+ }
+ sx={{ alignSelf: 'flex-start' }}
+ onClick={() => {
+ setHistoryFilters({
+ standard: row.standardLabel ? [row.standardLabel] : [],
+ outcome: [],
+ mode: [],
+ search: '',
+ })
+ setHistoryLimit(50)
+ setViewMode('history')
+ }}
+ >
+ View full history
+
+
+
+ );
+ },
+ }
+
+ const standardOffCanvas = {
+ size: 'md',
+ title: 'Standard Tenant Summary',
+ contentPadding: 0,
+ children: (row) => (
+
+ {propertyList([
+ { label: 'Standard', value: row.standardLabel },
+ { label: 'Category', value: row.category },
+ { label: 'Impact', value: row.impact },
+ {
+ label: 'Compliant with accepted deviations',
+ value: `${row.alignedPercentage}%`,
+ },
+ {
+ label: 'Compliant with baseline',
+ value: `${row.verifiedPercentage}%`,
+ },
+ { label: 'Accepted Deviations', value: row.accepted },
+ { label: 'License Missing', value: row.licenseMissing },
+ {
+ label: 'Secure Score Impact',
+ value: row.secureScoreImpact
+ ? `+${row.secureScoreImpact} points`
+ : 'None',
+ },
+ ])}
+ {/* A single point is just today's live score (already listed above) - the
+ chart earns its space once there is an actual line to draw. */}
+ {Array.isArray(row.trend) && row.trend.length > 1 && (
+
+ ({
+ x: point.date,
+ y: point.aligned,
+ })),
+ },
+ {
+ name: 'Compliant with baseline',
+ data: row.trend.map((point) => ({
+ x: point.date,
+ y: point.verified,
+ })),
+ },
+ ]}
+ />
+
+ )}
+
+
+ Tenant States
+
+ {/* The offcanvas renders with an empty row until one is selected. */}
+ {(row.rows ?? []).map((tenantRow) => (
+
+
+
+
+ {tenantRow.tenantName}
+
+
+ {tenantRow.tenantFilter}
+
+
+
+
+ {tenantRow.deviationReason && (
+
+ {tenantRow.deviationReason}
+
+ )}
+ {([
+ 'Drift',
+ 'Partially Accepted',
+ 'Denied - Remediate Pending',
+ ].includes(tenantRow.status) ||
+ tenantRow.sourceTemplate === 'Tenant Override') && (
+
+ {[
+ 'Drift',
+ 'Partially Accepted',
+ 'Denied - Remediate Pending',
+ ].includes(tenantRow.status) && (
+ <>
+ }
+ onClick={() => {
+ setTriageTarget(tenantRow)
+ triageDialog.handleOpen()
+ }}
+ >
+ Accept Deviation
+
+ {tenantRow.sourceTemplate !== 'Tenant Override' && (
+ }
+ onClick={() => {
+ setOverrideTarget(tenantRow)
+ overrideDialog.handleOpen()
+ }}
+ >
+ Tenant Override
+
+ )}
+ >
+ )}
+ {tenantRow.sourceTemplate === 'Tenant Override' && (
+ }
+ onClick={() => {
+ setRemoveOverrideTarget(tenantRow)
+ removeOverrideDialog.handleOpen()
+ }}
+ >
+ Remove Override
+
+ )}
+
+ )}
+
+ ))}
+
+
+ ),
+ }
+
+ const templateActions = [
+ {
+ label: 'Edit Baseline',
+ link: '/tenant/baselines/template?id=[GUID]',
+ pinned: true,
+ icon: ,
+ color: 'success',
+ target: '_self',
+ },
+ {
+ label: 'Run Baseline Now',
+ type: 'POST',
+ url: '/api/ExecBaselineRun',
+ icon: ,
+ color: 'info',
+ data: { mode: '!run', templateId: 'GUID' },
+ children: ({ formHook, row }) => (
+
+
+
+ ),
+ confirmText:
+ 'Run [templateName] now? Pick a single covered tenant, or All Tenants in Template for the whole assignment. Standards in report-only stages are compared without changes.',
+ multiPost: false,
+ relatedQueryKeys,
+ },
+ ]
+
+ const templateOffCanvas = {
+ size: 'md',
+ title: 'Baseline Rollout',
+ contentPadding: 0,
+ children: (row) => {
+ // The offcanvas renders with an empty row until one is selected.
+ const tenantStates = row.tenantStates ?? []
+ return (
+
+ {propertyList([
+ { label: 'Baseline', value: row.templateName },
+ { label: 'Description', value: row.description },
+ { label: 'Standards', value: row.standardsCount },
+ { label: 'Stages', value: (row.stageNames ?? []).join(' -> ') },
+ {
+ label: 'Assigned To',
+ value: (row.assignedTenants ?? []).join(', '),
+ },
+ { label: 'Remediation', value: row.remediationPosture },
+ ])}
+
+
+ Tenant Stage Progress
+
+ {tenantStates.length === 0 && (
+
+ No tenants are currently tracked in this rollout.
+
+ )}
+ {tenantStates.map((state) => (
+
+
+
+
+ {state.tenantName}
+
+
+ Entered{' '}
+ {parseCippDate(state.enteredStageAt).toLocaleDateString()}
+
+
+
+
+ {state.manualAdvance && (
+ }
+ onClick={() => {
+ setAdvanceTarget({
+ tenantFilter: state.tenantFilter,
+ templateId: row.GUID,
+ templateName: row.templateName,
+ nextStageName: state.nextStageName,
+ })
+ advanceDialog.handleOpen()
+ }}
+ >
+ Move to Next Stage
+
+ )}
+
+
+ {state.nextStage ? (
+
+ Next: Stage {state.currentStage + 1} ({state.nextStageName})
+ - advances when {describeStageConditions(state.nextStage)}
+ {state.estimatedAdvanceAt
+ ? `, estimated ${parseCippDate(state.estimatedAdvanceAt).toLocaleDateString()}`
+ : ''}
+
+ ) : (
+
+ Final stage - the full template is applied.
+
+ )}
+
+ ))}
+
+
+ );
+ },
+ }
+
+ const tenantFilterList = [
+ {
+ filterName: 'Open Deviations',
+ value: [{ id: 'status', value: 'Drift' }],
+ type: 'column',
+ },
+ {
+ filterName: 'Accepted',
+ value: [{ id: 'status', value: 'Accepted' }],
+ type: 'column',
+ },
+ {
+ filterName: 'Denied',
+ value: [{ id: 'status', value: 'Denied - Remediate Pending' }],
+ type: 'column',
+ },
+ {
+ filterName: 'License Missing',
+ value: [{ id: 'status', value: 'Skipped - No License' }],
+ type: 'column',
+ },
+ ]
+
+ const standardFilterList = [
+ {
+ filterName: 'Has Open Deviations',
+ value: [{ id: 'drift', value: 1 }],
+ type: 'column',
+ },
+ {
+ filterName: 'Has Accepted Deviations',
+ value: [{ id: 'accepted', value: 1 }],
+ type: 'column',
+ },
+ {
+ filterName: 'Has License Missing',
+ value: [{ id: 'licenseMissing', value: 1 }],
+ type: 'column',
+ },
+ ]
+
+ // Page-level view selector, shown above the score bar and table.
+ const modeToggle = (
+ {
+ if (newViewMode !== null) setViewMode(newViewMode)
+ }}
+ sx={{
+ // Four options do not fit a phone viewport on one line, so wrap below md.
+ flexWrap: { xs: 'wrap', md: 'nowrap' },
+ '& .MuiToggleButton-root': { py: 0.5, px: 1.5, fontSize: '0.8125rem' },
+ }}
+ >
+
+
+
+
+ Tenant View
+
+
+
+
+
+
+
+ Standard View
+
+
+
+
+
+
+
+ Baseline View
+
+
+
+
+
+
+
+ Historic View
+
+
+
+
+ )
+
+ const rolloutCard = (
+
+ {stageStates.length === 0 && (
+
+ No baselines are assigned to this tenant.
+
+ )}
+
+ {[...stageStates]
+ .sort((a, b) =>
+ String(a.templateName).localeCompare(String(b.templateName))
+ )
+ .map((state) => (
+
+
+
+
+ {state.templateName}
+
+
+ Entered{' '}
+ {parseCippDate(state.enteredStageAt).toLocaleDateString()}
+
+
+
+ {state.alignedPercentage !== null && (
+
+
+
+ )}
+
+ {!state.nextStage && (
+
+ )}
+ {state.manualAdvance && (
+
+
+
+ )}
+
+ {state.nextStage && (
+
+ Next: Stage {state.currentStage + 1} ({state.nextStageName})
+ - advances when {describeStageConditions(state.nextStage)}
+
+ )}
+ {state.manualAdvance && (
+ <>
+
+
+
+ }
+ onClick={() => {
+ setAdvanceTarget({
+ tenantFilter: tenant.tenantId,
+ templateId: state.templateId,
+ templateName: state.templateName,
+ nextStageName: state.nextStageName,
+ })
+ advanceDialog.handleOpen()
+ }}
+ >
+ Move to next stage ({state.nextStageName})
+
+
+ >
+ )}
+
+
+ ))}
+
+
+ )
+
+ const tenantScoreBar = (
+ ,
+ name: 'Compliant with accepted deviations',
+ data: `${tenant.alignedPercentage}%`,
+ color: 'success',
+ toolTip: `${tenant.acceptedPercentage}% of this score comes from accepted deviations`,
+ },
+ {
+ icon: ,
+ name: 'Compliant with baseline',
+ data: `${tenant.verifiedPercentage}%`,
+ },
+ {
+ icon: ,
+ name: 'Open Deviations',
+ data: tenant.drift,
+ color: 'error',
+ },
+ {
+ icon: ,
+ name: 'License Missing',
+ data: `${tenant.total ? Math.round((tenant.licenseMissing / tenant.total) * 100) : 0}%`,
+ color: 'warning',
+ toolTip: `${tenant.licenseMissing} standard${tenant.licenseMissing === 1 ? '' : 's'} excluded from scoring because the tenant lacks the license`,
+ },
+ ]}
+ />
+ )
+
+ const overrideStandard = overrideTarget
+ ? catalog.find((entry) => entry.name === overrideTarget.standardName)
+ : null
+
+ // The triage/override/advance dialogs are shared between the tenant layout and the
+ // table page for the other views.
+ const dialogs = (
+ <>
+ {advanceTarget && (
+
+ )}
+ {triageTarget && (
+
+ )}
+ {overrideTarget && overrideStandard && (
+ (
+
+
+ The settings below are pre-filled with what{' '}
+ {overrideTarget.sourceTemplate} currently applies to{' '}
+ {overrideTarget.tenantName}. Saving creates a tenant-specific
+ override that replaces them.
+
+
+
+ )}
+ api={{
+ url: '/api/ExecBaselineOverride',
+ type: 'POST',
+ data: {
+ action: '!createOverride',
+ tenantFilter: 'tenantFilter',
+ standard: 'standardName',
+ },
+ confirmText:
+ 'Create a tenant-specific override of [standardLabel] for [tenantFilter]?',
+ relatedQueryKeys,
+ }}
+ row={overrideTarget}
+ />
+ )}
+ {acceptPathTarget && (
+
+ )}
+ {denyPathTarget && (
+
+ )}
+ {removeOverrideTarget && (
+
+ )}
+ >
+ )
+
+ // Historic view: every recorded baseline event for the tenant on an activity
+ // timeline (same pattern as the manage-tenant history page). Engine runs touch
+ // many standards under one run GUID, so those group into a collapsible summary
+ // entry; operator events (triage, overrides, stage changes, deletions) stand on
+ // their own. View Logs opens the Baselines log drawer filtered to one run.
+ if (viewMode === 'history') {
+ const historyEvents = historyApi.data?.events ?? []
+ const standardOptions = [
+ ...new Set(historyEvents.map((event) => event.standardLabel)),
+ ]
+ .filter(Boolean)
+ .sort()
+ .map((value) => ({ label: value, value }))
+ const outcomeOptions = [
+ ...new Set(historyEvents.map((event) => event.outcome)),
+ ]
+ .filter(Boolean)
+ .sort()
+ .map((value) => ({
+ label: outcomeTimeline[value]?.label ?? value,
+ value,
+ }))
+ const modeOptions = [...new Set(historyEvents.map((event) => event.mode))]
+ .filter(Boolean)
+ .map((value) => ({ label: runModeLabels[value] ?? value, value }))
+ const searchTerm = historyFilters.search.trim().toLowerCase()
+ const filteredEvents = historyEvents.filter(
+ (event) =>
+ (historyFilters.standard.length === 0 ||
+ historyFilters.standard.includes(event.standardLabel)) &&
+ (historyFilters.outcome.length === 0 ||
+ historyFilters.outcome.includes(event.outcome)) &&
+ (historyFilters.mode.length === 0 ||
+ historyFilters.mode.includes(event.mode)) &&
+ (!searchTerm ||
+ `${event.standardLabel} ${event.outcome} ${event.detail ?? ''} ${event.triggeredBy}`
+ .toLowerCase()
+ .includes(searchTerm))
+ )
+ // Group by run GUID (newest-first order preserved); multi-event groups render
+ // as one collapsible summary. Flattening to render rows up front lets the
+ // timeline connector stop at the true last item.
+ const runGroups = []
+ const groupIndex = new Map()
+ for (const event of filteredEvents) {
+ const key = String(event.runId ?? 'unknown')
+ if (groupIndex.has(key)) {
+ runGroups[groupIndex.get(key)].events.push(event)
+ } else {
+ groupIndex.set(key, runGroups.length)
+ runGroups.push({ runId: key, events: [event] })
+ }
+ }
+ const visibleGroups = runGroups.slice(0, historyLimit)
+ const renderRows = []
+ for (const group of visibleGroups) {
+ if (group.events.length === 1) {
+ renderRows.push({ type: 'event', event: group.events[0] })
+ } else {
+ renderRows.push({ type: 'group', group })
+ if (expandedRuns.has(group.runId)) {
+ for (const event of group.events) {
+ renderRows.push({ type: 'event', event })
+ }
+ }
+ }
+ }
+ return (
+ <>
+
+
+
+
+ {modeToggle}
+
+
+
+ This timeline shows every recorded baseline event for{' '}
+ {tenant.displayName} - runs, operator decisions, stage changes,
+ and deletions.
+
+
+
+
+ setHistoryFilter('search', event.target.value)
+ }
+ autoComplete="off"
+ placeholder="Search by standard, outcome, or operator..."
+ slotProps={{
+ input: {
+ startAdornment: (
+
+ ),
+ }
+ }}
+ />
+
+
+ ({
+ label: value,
+ value,
+ }))}
+ onChange={(newValue) =>
+ setHistoryFilter(
+ 'standard',
+ Array.isArray(newValue)
+ ? newValue.map((option) => option.value)
+ : []
+ )
+ }
+ />
+
+
+ ({
+ label: outcomeTimeline[value]?.label ?? value,
+ value,
+ }))}
+ onChange={(newValue) =>
+ setHistoryFilter(
+ 'outcome',
+ Array.isArray(newValue)
+ ? newValue.map((option) => option.value)
+ : []
+ )
+ }
+ />
+
+
+ ({
+ label: runModeLabels[value] ?? value,
+ value,
+ }))}
+ onChange={(newValue) =>
+ setHistoryFilter(
+ 'mode',
+ Array.isArray(newValue)
+ ? newValue.map((option) => option.value)
+ : []
+ )
+ }
+ />
+
+
+ {historyApi.isFetching && (
+
+
+
+ )}
+ {!historyApi.isFetching && historyEvents.length === 0 && (
+
+ No baseline run history for this tenant yet - run a baseline
+ first.
+
+ )}
+ {!historyApi.isFetching &&
+ historyEvents.length > 0 &&
+ filteredEvents.length === 0 && (
+
+ No events match the current filters.
+
+ )}
+ {renderRows.length > 0 && (
+
+
+
+ {renderRows.map((row, index) => {
+ // Collapsed engine run: one summary entry with per-outcome
+ // counts; expanding reveals the individual standards below.
+ if (row.type === 'group') {
+ const group = row.group
+ const first = group.events[0]
+ const groupDate = parseCippDate(first.timestamp)
+ const outcomeCounts = {}
+ for (const groupEvent of group.events) {
+ outcomeCounts[groupEvent.outcome] =
+ (outcomeCounts[groupEvent.outcome] ?? 0) + 1
+ }
+ const severityRank = {
+ error: 4,
+ warning: 3,
+ info: 2,
+ success: 1,
+ }
+ const dotColor = group.events.reduce(
+ (worst, groupEvent) => {
+ const color =
+ outcomeTimeline[groupEvent.outcome]?.color ??
+ 'grey'
+ return (severityRank[color] ?? 0) >
+ (severityRank[worst] ?? 0)
+ ? color
+ : worst
+ },
+ 'grey'
+ )
+ const isOpen = expandedRuns.has(group.runId)
+ const alertedCount = group.events.filter(
+ (groupEvent) => groupEvent.alerted
+ ).length
+ return (
+
+
+
+ {groupDate.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ })}
+
+
+ {groupDate.toLocaleTimeString('en-US', {
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ })}
+
+
+
+
+ {first.mode === 'compare' ? (
+
+ ) : (
+
+ )}
+
+ {index < renderRows.length - 1 && (
+
+ )}
+
+
+
+
+
+
+
+
+ {Object.entries(outcomeCounts).map(
+ ([outcome, count]) => (
+
+ )
+ )}
+ {alertedCount > 0 && (
+
+ )}
+
+
+ Processed {group.events.length} standards in
+ this run
+
+
+
+ toggleRunExpansion(group.runId)
+ }
+ sx={{
+ textAlign: 'left',
+ fontSize: '0.75rem',
+ }}
+ >
+ {isOpen
+ ? 'Hide the individual standards'
+ : `View all ${group.events.length} standards`}
+
+
+
+
+ Triggered by {first.triggeredBy}
+
+
+
+
+ );
+ }
+ const event = row.event
+ const timelineConfig = outcomeTimeline[event.outcome] ?? {
+ color: 'grey',
+ chipColor: 'default',
+ icon: ,
+ }
+ const eventDate = parseCippDate(event.timestamp)
+ const eventKey = `${event.runId}-${event.standardName}-${event.outcome}-${event.timestamp}`
+ const isExpanded = expandedEvents.has(eventKey)
+ const diffEntries = event.diff
+ ? Array.isArray(event.diff)
+ ? event.diff
+ : [event.diff]
+ : []
+ return (
+
+
+
+ {eventDate.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ })}
+
+
+ {eventDate.toLocaleTimeString('en-US', {
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ })}
+
+
+
+
+ {timelineConfig.icon}
+
+ {index < renderRows.length - 1 && (
+
+ )}
+
+
+
+
+
+
+
+
+
+ {event.alerted && (
+
+ )}
+
+
+
+ {historyEventMessage(event)}
+
+
+
+ toggleEventExpansion(eventKey)
+ }
+ sx={{
+ textAlign: 'left',
+ fontSize: '0.75rem',
+ }}
+ >
+ {isExpanded
+ ? 'Hide details'
+ : 'View details'}
+
+
+
+ {isExpanded && (
+
+ {diffEntries.map((entry, diffIndex) => (
+
+ {entry?.Property}: expected{' '}
+ {JSON.stringify(entry?.ExpectedValue)},
+ found{' '}
+ {JSON.stringify(entry?.ReceivedValue)}
+
+ ))}
+
+ Run ID: {event.runId}
+
+
+ )}
+
+
+ Triggered by {event.triggeredBy}
+
+
+
+
+ );
+ })}
+
+
+
+ )}
+ {runGroups.length > historyLimit && (
+ setHistoryLimit((prev) => prev + 50)}
+ >
+ Load more (showing {historyLimit} of {runGroups.length} entries)
+
+ )}
+ {dialogs}
+
+
+ >
+ );
+ }
+
+ // Tenant view: custom layout so the deviation feed sits directly next to the
+ // alignment table.
+ if (isTenantView) {
+ return (
+ <>
+
+
+
+
+ {modeToggle}
+
+
+
+
+
+ {tenantScoreBar}
+ {rolloutCard}
+
+ {dialogs}
+
+
+ >
+ );
+ }
+
+ return (
+
+
+ {modeToggle}
+
+ {dialogs}
+
+ }
+ actions={isTemplateView ? templateActions : standardActions}
+ filters={isTemplateView ? undefined : standardFilterList}
+ offCanvas={isTemplateView ? templateOffCanvas : standardOffCanvas}
+ offCanvasOnRowClick={true}
+ simpleColumns={
+ isTemplateView
+ ? [
+ 'baselineName',
+ 'standardsCount',
+ 'stageNames',
+ 'assignedTenants',
+ 'remediationPosture',
+ 'updatedAt',
+ ]
+ : [
+ 'standardLabel',
+ 'category',
+ 'impact',
+ 'alignedPercentage',
+ 'verifiedPercentage',
+ 'accepted',
+ 'drift',
+ 'licenseMissing',
+ 'totalTenants',
+ ]
+ }
+ queryKey={`ListBaselineAlignment-${viewMode}-table`}
+ />
+ );
+}
+
+Page.getLayout = (page) => (
+
+ {page}
+
+)
+
+export default Page
+
diff --git a/src/pages/tenant/baselines/index.js b/src/pages/tenant/baselines/index.js
deleted file mode 100644
index d187b21dee8b..000000000000
--- a/src/pages/tenant/baselines/index.js
+++ /dev/null
@@ -1,325 +0,0 @@
-import {
- Box,
- Button,
- Card,
- CardHeader,
- Container,
- Divider,
- LinearProgress,
- Stack,
- Tooltip,
- Typography,
-} from '@mui/material'
-import { Grid } from '@mui/system'
-import { useRouter } from 'next/router'
-import {
- CheckBadgeIcon,
- ExclamationTriangleIcon,
- KeyIcon,
- ShieldCheckIcon,
-} from '@heroicons/react/24/outline'
-import { Layout as DashboardLayout } from '../../../layouts/index.js'
-import { TabbedLayout } from '../../../layouts/TabbedLayout'
-import tabOptions from './tabOptions.json'
-import { CippHead } from '../../../components/CippComponents/CippHead'
-import { CippInfoBar } from '../../../components/CippCards/CippInfoBar'
-import { CippChartCard } from '../../../components/CippCards/CippChartCard'
-import { CippDataTable } from '../../../components/CippTable/CippDataTable'
-import { ApiGetCall } from '../../../api/ApiCall'
-import { ResourceUnavailable } from '../../../components/resource-unavailable'
-import { useSettings } from '../../../hooks/use-settings'
-
-// CippChartCard shows a skeleton while its series is empty; once the query settles with no
-// data (no baseline runs yet) we show a real empty state instead of a permanent skeleton.
-const EmptyChartCard = ({ title, message }) => (
-
-
-
-
-
-)
-
-// The API serializes single-element arrays as a bare object; charts need real arrays.
-const asArray = (value) => (Array.isArray(value) ? value : value ? [value] : [])
-
-const Page = () => {
- const pageTitle = 'Fleet Overview'
- const router = useRouter()
- const settings = useSettings()
-
- const aggregate = ApiGetCall({
- url: '/api/ListBaselineAlignment',
- data: { byStandard: true },
- queryKey: 'ListBaselineAlignment-byStandard',
- })
-
- const fleetScore = aggregate.data?.fleet
- // The baselines list tells first-run apart from "configured but not run yet".
- const baselinesApi = ApiGetCall({
- url: '/api/ListBaselines',
- queryKey: 'ListBaselines',
- })
- const baselineCount = asArray(baselinesApi.data).length
- const isFirstRun =
- !aggregate.isFetching &&
- !baselinesApi.isFetching &&
- baselineCount === 0 &&
- (fleetScore?.total ?? 0) === 0
- const trend = asArray(aggregate.data?.trend)
- const tenantsNeedingAttention = asArray(aggregate.data?.tenants)
- .slice()
- .sort((a, b) => a.alignedPercentage - b.alignedPercentage)
- .slice(0, 5)
- const activeDeviations = asArray(aggregate.data?.activeDeviations)
- const licenseMissingPercentage = fleetScore?.total
- ? Math.round((fleetScore.licenseMissing / fleetScore.total) * 100)
- : 0
-
- // First run: nothing exists yet, so charts and tables would all be empty
- // shells. Replace the dashboard with the three steps that make it light up.
- if (isFirstRun) {
- return (
- <>
-
-
-
-
-
-
-
- A baseline is the desired configuration for your tenants. CIPP
- checks every tenant against it twice a day, shows exactly what
- deviates, and - if you want - fixes it automatically.
-
-
- 1. Create a baseline and add standards from the catalog.
-
- 2. Assign the tenants or tenant groups it applies to.
-
- 3. Save and run the first check - no changes are made until you
- enable automatic fixing per standard.
-
-
- router.push('/tenant/baselines/template')}
- >
- Create your first baseline
-
- router.push('/tenant/baselines/templates')}
- >
- Browse the community catalog
-
-
-
-
-
- >
- )
- }
-
- return (
- <>
-
-
-
- ,
- name: 'Compliant with accepted deviations',
- data: `${fleetScore?.alignedPercentage ?? 0}%`,
- color: 'success',
- toolTip: `${fleetScore?.acceptedPercentage ?? 0}% of this score comes from accepted deviations`,
- },
- {
- icon: ,
- name: 'Compliant with baseline',
- data: `${fleetScore?.verifiedPercentage ?? 0}%`,
- toolTip:
- 'Standards currently in their expected state, with accepted deviations NOT counted.',
- },
- {
- icon: ,
- name: 'Open Deviations',
- data: fleetScore?.drift ?? 0,
- color: 'error',
- toolTip:
- 'Drift awaiting triage (Accept / Deny / Remediate) - click to review',
- onClick: () =>
- router.push('/tenant/baselines/alignment?status=Drift'),
- },
- {
- icon: ,
- name: 'License Missing',
- data: `${licenseMissingPercentage}%`,
- color: 'warning',
- toolTip: `${fleetScore?.licenseMissing ?? 0} standard instance${(fleetScore?.licenseMissing ?? 0) === 1 ? '' : 's'} excluded from scoring because the tenant lacks the license - click to review`,
- onClick: () =>
- router.push(
- '/tenant/baselines/alignment?status=Skipped - No License'
- ),
- },
- ]}
- />
-
-
- {!aggregate.isFetching && trend.length === 0 ? (
-
- ) : (
- 0
- ? [
- {
- name: 'Compliant with accepted deviations',
- data: trend.map((point) => ({
- x: point.date,
- y: point.aligned,
- })),
- },
- {
- name: 'Compliant with baseline',
- data: trend.map((point) => ({
- x: point.date,
- y: point.verified,
- })),
- },
- ]
- : []
- }
- />
- )}
-
-
- {!aggregate.isFetching && (fleetScore?.total ?? 0) === 0 ? (
-
- ) : (
-
- )}
-
-
-
-
- {!aggregate.isFetching && tenantsNeedingAttention.length === 0 ? (
-
- ) : (
-
-
-
-
- {tenantsNeedingAttention.map((tenant) => (
- {
- // Land on the alignment page AS this tenant, not whatever
- // tenant the global selector happened to hold.
- settings.handleUpdate({
- currentTenant: tenant.tenantFilter,
- })
- router.push('/tenant/baselines/alignment')
- }}
- sx={{ cursor: 'pointer' }}
- >
-
-
-
- {tenant.displayName}
-
-
-
- {tenant.alignedPercentage}%
-
-
-
-
- ))}
-
-
- )}
-
-
-
-
-
-
-
- >
- )
-}
-
-Page.getLayout = (page) => (
-
- {page}
-
-)
-
-export default Page
diff --git a/src/pages/tenant/baselines/index.jsx b/src/pages/tenant/baselines/index.jsx
new file mode 100644
index 000000000000..715d43e434d4
--- /dev/null
+++ b/src/pages/tenant/baselines/index.jsx
@@ -0,0 +1,326 @@
+import {
+ Box,
+ Button,
+ Card,
+ CardHeader,
+ Container,
+ Divider,
+ LinearProgress,
+ Stack,
+ Tooltip,
+ Typography,
+} from '@mui/material'
+import { CippIcons } from '../../../utils/icon-registry'
+import { Grid } from '@mui/system'
+import { useRouter } from 'next/router'
+import { Layout as DashboardLayout } from '../../../layouts/index'
+import { TabbedLayout } from '../../../layouts/TabbedLayout'
+import tabOptions from './tabOptions.json'
+import { CippHead } from '../../../components/CippComponents/CippHead'
+import { CippInfoBar } from '../../../components/CippCards/CippInfoBar'
+import { CippChartCard } from '../../../components/CippCards/CippChartCard'
+import { CippDataTable } from '../../../components/CippTable/CippDataTable'
+import { ApiGetCall } from '../../../api/ApiCall'
+import { ResourceUnavailable } from '../../../components/resource-unavailable'
+import { useSettings } from '../../../hooks/use-settings'
+
+// CippChartCard shows a skeleton while its series is empty; once the query settles with no
+// data (no baseline runs yet) we show a real empty state instead of a permanent skeleton.
+const EmptyChartCard = ({ title, message }) => (
+
+
+
+
+
+)
+
+// The API serializes single-element arrays as a bare object; charts need real arrays.
+const asArray = (value) => (Array.isArray(value) ? value : value ? [value] : [])
+
+const Page = () => {
+ const pageTitle = 'Fleet Overview'
+ const router = useRouter()
+ const settings = useSettings()
+
+ const aggregate = ApiGetCall({
+ url: '/api/ListBaselineAlignment',
+ data: { byStandard: true },
+ queryKey: 'ListBaselineAlignment-byStandard',
+ })
+
+ const fleetScore = aggregate.data?.fleet
+ // The baselines list tells first-run apart from "configured but not run yet".
+ const baselinesApi = ApiGetCall({
+ url: '/api/ListBaselines',
+ queryKey: 'ListBaselines',
+ })
+ const baselineCount = asArray(baselinesApi.data).length
+ const isFirstRun =
+ !aggregate.isFetching &&
+ !baselinesApi.isFetching &&
+ baselineCount === 0 &&
+ (fleetScore?.total ?? 0) === 0
+ const trend = asArray(aggregate.data?.trend)
+ const tenantsNeedingAttention = asArray(aggregate.data?.tenants)
+ .slice()
+ .sort((a, b) => a.alignedPercentage - b.alignedPercentage)
+ .slice(0, 5)
+ const activeDeviations = asArray(aggregate.data?.activeDeviations)
+ const licenseMissingPercentage = fleetScore?.total
+ ? Math.round((fleetScore.licenseMissing / fleetScore.total) * 100)
+ : 0
+
+ // First run: nothing exists yet, so charts and tables would all be empty
+ // shells. Replace the dashboard with the three steps that make it light up.
+ if (isFirstRun) {
+ return (
+ <>
+
+
+
+
+
+
+
+ A baseline is the desired configuration for your tenants. CIPP
+ checks every tenant against it twice a day, shows exactly what
+ deviates, and - if you want - fixes it automatically.
+
+
+ 1. Create a baseline and add standards from the catalog.
+
+ 2. Assign the tenants or tenant groups it applies to.
+
+ 3. Save and run the first check - no changes are made until you
+ enable automatic fixing per standard.
+
+
+ router.push('/tenant/baselines/template')}
+ >
+ Create your first baseline
+
+ router.push('/tenant/baselines/templates')}
+ >
+ Browse the community catalog
+
+
+
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+
+ ,
+ name: 'Compliant with accepted deviations',
+ data: `${fleetScore?.alignedPercentage ?? 0}%`,
+ color: 'success',
+ toolTip: `${fleetScore?.acceptedPercentage ?? 0}% of this score comes from accepted deviations`,
+ },
+ {
+ icon: ,
+ name: 'Compliant with baseline',
+ data: `${fleetScore?.verifiedPercentage ?? 0}%`,
+ toolTip:
+ 'Standards currently in their expected state, with accepted deviations NOT counted.',
+ },
+ {
+ icon: ,
+ name: 'Open Deviations',
+ data: fleetScore?.drift ?? 0,
+ color: 'error',
+ toolTip:
+ 'Drift awaiting triage (Accept / Deny / Remediate) - click to review',
+ onClick: () =>
+ router.push('/tenant/baselines/alignment?status=Drift'),
+ },
+ {
+ icon: ,
+ name: 'License Missing',
+ data: `${licenseMissingPercentage}%`,
+ color: 'warning',
+ toolTip: `${fleetScore?.licenseMissing ?? 0} standard instance${(fleetScore?.licenseMissing ?? 0) === 1 ? '' : 's'} excluded from scoring because the tenant lacks the license - click to review`,
+ onClick: () =>
+ router.push(
+ '/tenant/baselines/alignment?status=Skipped - No License'
+ ),
+ },
+ ]}
+ />
+
+
+ {!aggregate.isFetching && trend.length === 0 ? (
+
+ ) : (
+ 0
+ ? [
+ {
+ name: 'Compliant with accepted deviations',
+ data: trend.map((point) => ({
+ x: point.date,
+ y: point.aligned,
+ })),
+ },
+ {
+ name: 'Compliant with baseline',
+ data: trend.map((point) => ({
+ x: point.date,
+ y: point.verified,
+ })),
+ },
+ ]
+ : []
+ }
+ />
+ )}
+
+
+ {!aggregate.isFetching && (fleetScore?.total ?? 0) === 0 ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {!aggregate.isFetching && tenantsNeedingAttention.length === 0 ? (
+
+ ) : (
+
+
+
+
+ {tenantsNeedingAttention.map((tenant) => (
+ {
+ // Land on the alignment page AS this tenant, not whatever
+ // tenant the global selector happened to hold.
+ settings.handleUpdate({
+ currentTenant: tenant.tenantFilter,
+ })
+ router.push('/tenant/baselines/alignment')
+ }}
+ sx={{ cursor: 'pointer' }}
+ >
+
+
+
+ {tenant.displayName}
+
+
+
+ {tenant.alignedPercentage}%
+
+
+
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+
+ >
+ );
+}
+
+Page.getLayout = (page) => (
+
+ {page}
+
+)
+
+export default Page
diff --git a/src/pages/tenant/baselines/template.jsx b/src/pages/tenant/baselines/template.jsx
index f6ecf1b6986f..8c674d35734a 100644
--- a/src/pages/tenant/baselines/template.jsx
+++ b/src/pages/tenant/baselines/template.jsx
@@ -19,23 +19,13 @@ import {
Tooltip,
Typography,
} from '@mui/material'
+import { CippIcons } from '../../../utils/icon-registry'
import { Grid } from '@mui/system'
-import {
- Add,
- CheckCircle,
- ContentCopy,
- Delete,
- ExpandMore,
- PlayArrow,
- RadioButtonUnchecked,
- SaveRounded,
-} from '@mui/icons-material'
-import ArrowLeftIcon from '@mui/icons-material/ArrowLeft'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useForm, useWatch } from 'react-hook-form'
import { useRouter } from 'next/router'
import { get } from 'lodash'
-import { Layout as DashboardLayout } from '../../../layouts/index.js'
+import { Layout as DashboardLayout } from '../../../layouts/index'
import { CippHead } from '../../../components/CippComponents/CippHead'
import CippButtonCard from '../../../components/CippCards/CippButtonCard'
import { CippPropertyListCard } from '../../../components/CippCards/CippPropertyListCard'
@@ -43,7 +33,7 @@ import CippFormComponent from '../../../components/CippComponents/CippFormCompon
import { CippFormTenantSelector } from '../../../components/CippComponents/CippFormTenantSelector'
import CippBaselineStandardItem from '../../../components/CippBaselines/CippBaselineStandardItem'
import CippBaselineStandardDialog from '../../../components/CippBaselines/CippBaselineStandardDialog'
-import { PermissionButton } from '../../../utils/permissions.js'
+import { PermissionButton } from '../../../utils/permissions'
import { ApiGetCall, ApiPostCall } from '../../../api/ApiCall'
import { parseCippDate } from '../../../utils/parse-cipp-date'
import { CippApiResults } from '../../../components/CippComponents/CippApiResults'
@@ -226,6 +216,17 @@ const StagePanel = ({
value && typeof value === 'object' && 'value' in value
? value.value
: value
+ // Multi-select option arrays keep {label, value} so re-editing shows names without
+ // an option lookup, but shed everything else (addedFields, rawData) - persisting a
+ // full template object into the baseline bloats storage and the expected-value views.
+ const cleanVariableValue = (value) =>
+ Array.isArray(value)
+ ? value.map((item) =>
+ item && typeof item === 'object' && 'value' in item
+ ? { label: item.label ?? String(item.value), value: item.value }
+ : item
+ )
+ : unwrapValue(value)
registerSerializer(stageIndex, () => {
const values = formControl.getValues()
return {
@@ -258,7 +259,7 @@ const StagePanel = ({
instance: instanceKey,
variables: Object.fromEntries(
Object.entries(config.variables ?? savedVariables).map(
- ([key, value]) => [key, unwrapValue(value)]
+ ([key, value]) => [key, cleanVariableValue(value)]
)
),
// Report-only unless the operator explicitly enabled remediation - a
@@ -276,7 +277,9 @@ const StagePanel = ({
return (
-
+ onRemoveStage(stageIndex)}
color="error"
>
-
+
)}
@@ -325,7 +328,9 @@ const StagePanel = ({
Graduation conditions
-
+
A tenant advances from Stage {stageIndex} into this stage once
the conditions below are met. Earlier stages keep applying; if
the same standard is configured in both, this stage's settings
@@ -362,7 +367,9 @@ const StagePanel = ({
handleRemoveCondition(conditionId)}
>
-
+
@@ -461,13 +468,17 @@ const StagePanel = ({
)}
{conditionType === 'success' && (
-
+
Advances when every standard from the previous stages
reports Compliant for the tenant.
)}
{conditionType === 'manual' && (
-
+
An operator advances the tenant from the Alignment
page.
@@ -475,13 +486,13 @@ const StagePanel = ({
- )
+ );
})}
}
+ startIcon={}
onClick={handleAddCondition}
>
Add Condition
@@ -491,13 +502,15 @@ const StagePanel = ({
)}
-
+
Standards in this stage ({stageStandards.length})
}
+ endIcon={}
disabled={stageStandards.length === 0}
onClick={(event) => setBulkAnchor(event.currentTarget)}
>
@@ -522,14 +535,16 @@ const StagePanel = ({
}
+ startIcon={}
onClick={() => onOpenDialog(stageIndex)}
>
Add Standards
{stageStandards.length === 0 && (
-
+
No standards in this stage yet. Use Add Standards to browse the
catalog.
@@ -554,7 +569,7 @@ const StagePanel = ({
- )
+ );
}
const Page = () => {
@@ -909,7 +924,7 @@ const Page = () => {
onClick={() => router.back()}
startIcon={
-
+
}
>
@@ -918,11 +933,12 @@ const Page = () => {
+ sx={{
+ justifyContent: "space-between",
+ alignItems: { xs: 'stretch', sm: 'center' },
+ mb: 1
+ }}>
{pageTitle} {
requiredPermissions={['Tenant.Standards.ReadWrite']}
variant="contained"
color="primary"
- startIcon={}
+ startIcon={}
disabled={isSaveDisabled}
onClick={handleSave}
>
@@ -943,8 +959,8 @@ const Page = () => {
}
- endIcon={}
+ startIcon={}
+ endIcon={}
onClick={(event) => setAddStageAnchor(event.currentTarget)}
>
Add Stage
@@ -961,7 +977,7 @@ const Page = () => {
}}
>
-
+ Add empty stage
@@ -972,7 +988,7 @@ const Page = () => {
}}
>
-
+
Copy currently selected stage ({stages[activeStage]?.name})
@@ -990,7 +1006,7 @@ const Page = () => {
}
+ startIcon={}
disabled={runAfterSave.isPending}
onClick={() =>
runAfterSave.mutate({
@@ -1054,7 +1070,9 @@ const Page = () => {
label="Disable Scheduled Runs"
formControl={formControl}
/>
-
+
With scheduled runs disabled, this baseline only executes
when you run it yourself - drift is not detected or
remediated in between.
@@ -1075,7 +1093,9 @@ const Page = () => {
label="Custom webhook URL"
formControl={formControl}
/>
-
+
Alerts follow each standard's alert settings. Leave these
empty to deliver through the global CIPP notification
settings (email, webhook, and PSA).
@@ -1089,12 +1109,14 @@ const Page = () => {
key={step.label}
direction="row"
spacing={1}
- alignItems="center"
+ sx={{
+ alignItems: "center"
+ }}
>
{step.done ? (
-
+
) : (
-
@@ -1183,7 +1205,7 @@ const Page = () => {
onToggle={handleToggleStandard}
/>
- )
+ );
}
Page.getLayout = (page) => {page}
diff --git a/src/pages/tenant/baselines/templates/index.js b/src/pages/tenant/baselines/templates/index.js
deleted file mode 100644
index 4eeda181f51d..000000000000
--- a/src/pages/tenant/baselines/templates/index.js
+++ /dev/null
@@ -1,642 +0,0 @@
-import {
- Alert,
- Box,
- Button,
- Checkbox,
- Chip,
- CircularProgress,
- Divider,
- FormControlLabel,
- LinearProgress,
- List,
- ListItem,
- ListItemText,
- Stack,
- SvgIcon,
- Switch,
- Typography,
-} from '@mui/material'
-import Link from 'next/link'
-import { useState } from 'react'
-import {
- AddBox,
- CloudDownload,
- CopyAll,
- Delete,
- Edit,
- GitHub,
- PlayArrow,
- Upgrade,
-} from '@mui/icons-material'
-import { Layout as DashboardLayout } from '../../../../layouts/index.js'
-import { TabbedLayout } from '../../../../layouts/TabbedLayout'
-import tabOptions from '../tabOptions.json'
-import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx'
-import { CippFormTemplateTenantSelector } from '../../../../components/CippComponents/CippFormTemplateTenantSelector'
-import { CippOffCanvas } from '../../../../components/CippComponents/CippOffCanvas'
-import { CippTemplateCatalog } from '../../../../components/CippComponents/CippTemplateCatalog'
-import { describeStageConditions } from '../../../../components/CippBaselines/CippBaselineWhatIfReport'
-import { parseCippDate } from '../../../../utils/parse-cipp-date'
-import { ApiGetCall, ApiPostCall } from '../../../../api/ApiCall'
-import { CippApiResults } from '../../../../components/CippComponents/CippApiResults'
-
-// The API serializes single-element arrays as a bare object; the selector needs a real array.
-const asOptionArray = (value) =>
- (Array.isArray(value) ? value : value ? [value] : []).filter(
- (entry) => entry && typeof entry === 'object' && entry.value
- )
-
-const Page = () => {
- const pageTitle = 'Baselines'
- const [catalogVisible, setCatalogVisible] = useState(false)
- const [migrateVisible, setMigrateVisible] = useState(false)
- const [migrateSelected, setMigrateSelected] = useState([])
- const [migrateReportOnly, setMigrateReportOnly] = useState(true)
- const [migrateAddDetect, setMigrateAddDetect] = useState(false)
- const integrations = ApiGetCall({
- url: '/api/ListExtensionsConfig',
- queryKey: 'Integrations',
- })
- const migratePreview = ApiPostCall({
- onResult: (result) => {
- // Pre-select everything migratable; skipped/up-to-date rows stay untouched.
- setMigrateSelected(
- (result?.Metadata?.templates ?? [])
- .filter((template) =>
- ['Ready', 'WillUpdate'].includes(template.status)
- )
- .map((template) => template.v2Guid)
- )
- },
- })
- const migrateCommit = ApiPostCall({
- relatedQueryKeys: ['ListBaseline*'],
- })
- const openMigrate = () => {
- setMigrateVisible(true)
- migratePreview.mutate({
- url: '/api/ExecBaselineMigrate',
- data: { action: 'preview' },
- })
- }
- // A finished commit replaces the preview as the list's source, so each row shows
- // what actually happened to it.
- const migrationReport =
- migrateCommit.data?.data?.Metadata ?? migratePreview.data?.data?.Metadata
- const migrationTemplates = Array.isArray(migrationReport?.templates)
- ? migrationReport.templates
- : []
- const migrateStatusChip = {
- Ready: { color: 'info', label: 'Ready' },
- WillUpdate: { color: 'info', label: 'Will update' },
- Migrated: { color: 'success', label: 'Migrated' },
- Updated: { color: 'success', label: 'Updated' },
- UpToDate: { color: 'default', label: 'Up to date' },
- Skipped: { color: 'default', label: 'Skipped' },
- Failed: { color: 'error', label: 'Failed' },
- }
-
- const actions = [
- {
- label: 'Edit Baseline',
- link: '/tenant/baselines/template?id=[GUID]',
- icon: ,
- color: 'success',
- target: '_self',
- },
- {
- label: 'Clone & Edit Baseline',
- link: '/tenant/baselines/template?id=[GUID]&clone=true',
- icon: ,
- color: 'success',
- target: '_self',
- },
- {
- label: 'Run Baseline Now',
- type: 'POST',
- url: '/api/ExecBaselineRun',
- icon: ,
- color: 'info',
- data: { mode: '!run', templateId: 'GUID' },
- children: ({ formHook, row }) => (
-
-
-
- ),
- confirmText:
- 'Run [templateName] now? Pick a single covered tenant, or All Tenants in Template for the whole assignment. Standards in report-only stages are compared without changes.',
- multiPost: false,
- relatedQueryKeys: ['ListBaseline*'],
- },
- {
- label: 'Save to GitHub',
- type: 'POST',
- url: '/api/ExecCommunityRepo',
- icon: ,
- data: { Action: 'UploadBaseline', GUID: 'GUID' },
- fields: [
- {
- label: 'Repository',
- name: 'FullName',
- type: 'select',
- api: {
- url: '/api/ListCommunityRepos',
- data: { WriteAccess: true },
- queryKey: 'CommunityRepos-Write',
- dataKey: 'Results',
- valueField: 'FullName',
- labelField: 'FullName',
- },
- multiple: false,
- creatable: false,
- required: true,
- },
- {
- label: 'Commit Message',
- name: 'Message',
- type: 'textField',
- multiline: true,
- required: true,
- rows: 4,
- },
- ],
- confirmText:
- 'Save [templateName] to the selected repository? This uploads the baseline AND every CA/Intune template it references as separate files. Template packages are expanded to their current members, and tenant assignments are replaced with a placeholder.',
- condition: () =>
- integrations.isSuccess && integrations?.data?.GitHub?.Enabled,
- },
- {
- label: 'Delete Baseline',
- type: 'POST',
- url: '/api/RemoveBaseline',
- icon: ,
- color: 'error',
- data: { ID: 'GUID' },
- confirmText:
- 'Delete [templateName]? Its standards stop being resolved for the assigned tenants on the next run.',
- multiPost: false,
- relatedQueryKeys: ['ListBaseline*'],
- },
- ]
-
- const offCanvas = {
- size: 'md',
- title: 'Baseline Details',
- contentPadding: 0,
- children: (row) => {
- const occupancy = row.occupancy ?? []
- const totalTenantsInRollout = occupancy.reduce(
- (acc, stage) => acc + stage.tenants.length,
- 0
- )
- return (
-
- }
- sx={{ borderBottom: '1px solid', borderColor: 'divider' }}
- >
- {[
- { label: 'Baseline', value: row.templateName },
- { label: 'Description', value: row.description },
- { label: 'Standards', value: row.standardsCount },
- { label: 'Remediation', value: row.remediationPosture },
- {
- label: 'Scheduled Runs',
- value: row.disableScheduledRuns ? 'Disabled' : 'Enabled',
- },
- {
- label: 'Last Updated',
- value: row.updatedAt
- ? parseCippDate(row.updatedAt).toLocaleString()
- : 'N/A',
- },
- { label: 'Updated By', value: row.updatedBy },
- ].map(({ label, value }) => (
-
-
- {label}
-
-
- {value ?? 'N/A'}
-
-
- ))}
-
-
-
- Stage Progress
-
- {/* The offcanvas renders with an empty row until one is selected. */}
- {occupancy.map((stage) => (
-
-
-
-
- {stage.tenants.length} tenant
- {stage.tenants.length === 1 ? '' : 's'} -{' '}
- {stage.standardsCount} standard
- {stage.standardsCount === 1 ? '' : 's'}
-
-
-
- {stage.tenants.length > 0 && (
-
- {stage.tenants.join(', ')}
-
- )}
- {stage.nextAdvanceAt && (
-
- Next time-based advance out of this stage:{' '}
- {parseCippDate(stage.nextAdvanceAt).toLocaleDateString()}
-
- )}
- {/* Why tenants in this stage have not advanced: the NEXT stage's
- graduation conditions. */}
- {stage.stage < occupancy.length &&
- (row.stages ?? [])[stage.stage] && (
-
- Tenants advance when{' '}
- {describeStageConditions(row.stages[stage.stage])}.
-
- )}
-
- ))}
-
- Assigned Tenants
-
-
- {(row.assignedTenants ?? []).map((assigned) => (
-
- ))}
-
-
-
- )
- },
- }
-
- return (
-
-
-
-
- }
- >
- Add Baseline
-
- setCatalogVisible(true)}
- startIcon={
-
-
-
- }
- >
- Browse Catalog
-
-
-
-
- }
- >
- Migrate from Standards
-
- setMigrateVisible(false)}
- size="lg"
- footer={
-
-
- migrateCommit.mutate({
- url: '/api/ExecBaselineMigrate',
- data: {
- action: 'commit',
- templateIds: migrateSelected,
- reportOnly: migrateReportOnly,
- addDetectStandards: migrateAddDetect,
- },
- })
- }
- >
- Migrate {migrateSelected.length} template
- {migrateSelected.length === 1 ? '' : 's'}
-
- setMigrateVisible(false)}
- >
- Close
-
-
- }
- >
-
-
- Converts your classic Standards templates (including drift
- templates) into baselines. The originals are never modified,
- but while the Baselines feature is enabled the classic
- Standards and Drift pages and their scheduled runs are turned
- off - only one engine manages your tenants at a time.
-
-
- setMigrateReportOnly(event.target.checked)
- }
- />
- }
- label="Import everything as report-only (recommended) - re-enable auto-remediation per standard once you have reviewed the results"
- />
-
- setMigrateAddDetect(event.target.checked)
- }
- />
- }
- label="Migrated drift templates should also alert on Intune and Conditional Access policies that were not created from a template"
- />
-
- {migratePreview.isPending && (
-
-
-
- )}
- {!migratePreview.isPending && migrationTemplates.length === 0 && (
-
- No classic Standards templates were found to migrate.
-
- )}
-
- {migrationTemplates.map((template) => {
- const selectable = ['Ready', 'WillUpdate'].includes(
- template.status
- )
- const chip =
- migrateStatusChip[template.status] ??
- migrateStatusChip.Ready
- return (
-
-
- setMigrateSelected((prev) =>
- prev.includes(template.v2Guid)
- ? prev.filter((id) => id !== template.v2Guid)
- : [...prev, template.v2Guid]
- )
- }
- sx={{ mt: 0.5 }}
- />
-
-
- {template.templateName || '(unnamed template)'}
-
- {template.type === 'drift' && (
-
- )}
-
-
- {template.standardsCount} standard
- {template.standardsCount === 1 ? '' : 's'}
-
-
- }
- secondary={
-
- {(template.tenants ?? []).length > 0 && (
-
- Tenants: {(template.tenants ?? []).join(', ')}
-
- )}
- {template.detail && (
-
- {template.detail}
-
- )}
- {(template.warnings ?? []).map((warning) => (
-
- {warning}
-
- ))}
-
- }
- />
-
- )
- })}
-
-
-
- setCatalogVisible(false)}
- size="xl"
- footer={
-
- setCatalogVisible(false)}
- >
- Close
-
-
- }
- >
-
-
-
-
- >
- }
- simpleColumns={[
- 'baselineName',
- 'description',
- 'standardsCount',
- 'stageNames',
- 'assignedTenants',
- 'remediationPosture',
- 'updatedAt',
- 'updatedBy',
- ]}
- queryKey="ListBaselines-table"
- />
- )
-}
-
-Page.getLayout = (page) => (
-
- {page}
-
-)
-
-export default Page
diff --git a/src/pages/tenant/baselines/templates/index.jsx b/src/pages/tenant/baselines/templates/index.jsx
new file mode 100644
index 000000000000..ddad094de755
--- /dev/null
+++ b/src/pages/tenant/baselines/templates/index.jsx
@@ -0,0 +1,660 @@
+import {
+ Alert,
+ Box,
+ Button,
+ Checkbox,
+ Chip,
+ CircularProgress,
+ Divider,
+ FormControlLabel,
+ LinearProgress,
+ List,
+ ListItem,
+ ListItemText,
+ Stack,
+ SvgIcon,
+ Switch,
+ Typography,
+} from '@mui/material'
+import { CippIcons } from '../../../../utils/icon-registry'
+import Link from 'next/link'
+import { useState } from 'react'
+import { Layout as DashboardLayout } from '../../../../layouts/index'
+import { TabbedLayout } from '../../../../layouts/TabbedLayout'
+import tabOptions from '../tabOptions.json'
+import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx'
+import { CippFormTemplateTenantSelector } from '../../../../components/CippComponents/CippFormTemplateTenantSelector'
+import { CippOffCanvas } from '../../../../components/CippComponents/CippOffCanvas'
+import { CippTemplateCatalog } from '../../../../components/CippComponents/CippTemplateCatalog'
+import { describeStageConditions } from '../../../../components/CippBaselines/CippBaselineWhatIfReport'
+import { parseCippDate } from '../../../../utils/parse-cipp-date'
+import { ApiGetCall, ApiPostCall } from '../../../../api/ApiCall'
+import { CippApiResults } from '../../../../components/CippComponents/CippApiResults'
+
+// The API serializes single-element arrays as a bare object; the selector needs a real array.
+const asOptionArray = (value) =>
+ (Array.isArray(value) ? value : value ? [value] : []).filter(
+ (entry) => entry && typeof entry === 'object' && entry.value
+ )
+
+const Page = () => {
+ const pageTitle = 'Baselines'
+ const [catalogVisible, setCatalogVisible] = useState(false)
+ const [migrateVisible, setMigrateVisible] = useState(false)
+ const [migrateSelected, setMigrateSelected] = useState([])
+ const [migrateReportOnly, setMigrateReportOnly] = useState(true)
+ const [migrateAddDetect, setMigrateAddDetect] = useState(false)
+ const integrations = ApiGetCall({
+ url: '/api/ListExtensionsConfig',
+ queryKey: 'Integrations',
+ })
+ const migratePreview = ApiPostCall({
+ onResult: (result) => {
+ // Pre-select everything migratable; skipped/up-to-date rows stay untouched.
+ setMigrateSelected(
+ (result?.Metadata?.templates ?? [])
+ .filter((template) =>
+ ['Ready', 'WillUpdate'].includes(template.status)
+ )
+ .map((template) => template.v2Guid)
+ )
+ },
+ })
+ const migrateCommit = ApiPostCall({
+ relatedQueryKeys: ['ListBaseline*'],
+ })
+ const openMigrate = () => {
+ setMigrateVisible(true)
+ migratePreview.mutate({
+ url: '/api/ExecBaselineMigrate',
+ data: { action: 'preview' },
+ })
+ }
+ // A finished commit replaces the preview as the list's source, so each row shows
+ // what actually happened to it.
+ const migrationReport =
+ migrateCommit.data?.data?.Metadata ?? migratePreview.data?.data?.Metadata
+ const migrationTemplates = Array.isArray(migrationReport?.templates)
+ ? migrationReport.templates
+ : []
+ const migrateStatusChip = {
+ Ready: { color: 'info', label: 'Ready' },
+ WillUpdate: { color: 'info', label: 'Will update' },
+ Migrated: { color: 'success', label: 'Migrated' },
+ Updated: { color: 'success', label: 'Updated' },
+ UpToDate: { color: 'default', label: 'Up to date' },
+ Skipped: { color: 'default', label: 'Skipped' },
+ Failed: { color: 'error', label: 'Failed' },
+ }
+
+ const actions = [
+ {
+ label: 'Edit Baseline',
+ link: '/tenant/baselines/template?id=[GUID]',
+ pinned: true,
+ icon: ,
+ color: 'success',
+ target: '_self',
+ },
+ {
+ label: 'Clone & Edit Baseline',
+ link: '/tenant/baselines/template?id=[GUID]&clone=true',
+ icon: ,
+ color: 'success',
+ target: '_self',
+ },
+ {
+ label: 'Run Baseline Now',
+ type: 'POST',
+ url: '/api/ExecBaselineRun',
+ icon: ,
+ color: 'info',
+ data: { mode: '!run', templateId: 'GUID' },
+ children: ({ formHook, row }) => (
+
+
+
+ ),
+ confirmText:
+ 'Run [templateName] now? Pick a single covered tenant, or All Tenants in Template for the whole assignment. Standards in report-only stages are compared without changes.',
+ multiPost: false,
+ relatedQueryKeys: ['ListBaseline*'],
+ },
+ {
+ label: 'Save to GitHub',
+ type: 'POST',
+ url: '/api/ExecCommunityRepo',
+ icon: ,
+ data: { Action: 'UploadBaseline', GUID: 'GUID' },
+ fields: [
+ {
+ label: 'Repository',
+ name: 'FullName',
+ type: 'select',
+ api: {
+ url: '/api/ListCommunityRepos',
+ data: { WriteAccess: true },
+ queryKey: 'CommunityRepos-Write',
+ dataKey: 'Results',
+ valueField: 'FullName',
+ labelField: 'FullName',
+ },
+ multiple: false,
+ creatable: false,
+ required: true,
+ },
+ {
+ label: 'Commit Message',
+ name: 'Message',
+ type: 'textField',
+ multiline: true,
+ required: true,
+ rows: 4,
+ },
+ ],
+ confirmText:
+ 'Save [templateName] to the selected repository? This uploads the baseline AND every CA/Intune template it references as separate files. Template packages are expanded to their current members, and tenant assignments are replaced with a placeholder.',
+ condition: () =>
+ integrations.isSuccess && integrations?.data?.GitHub?.Enabled,
+ },
+ {
+ label: 'Delete Baseline',
+ type: 'POST',
+ url: '/api/RemoveBaseline',
+ icon: ,
+ color: 'error',
+ data: { ID: 'GUID' },
+ confirmText:
+ 'Delete [templateName]? Its standards stop being resolved for the assigned tenants on the next run.',
+ multiPost: false,
+ relatedQueryKeys: ['ListBaseline*'],
+ },
+ ]
+
+ const offCanvas = {
+ size: 'md',
+ title: 'Baseline Details',
+ contentPadding: 0,
+ children: (row) => {
+ const occupancy = row.occupancy ?? []
+ const totalTenantsInRollout = occupancy.reduce(
+ (acc, stage) => acc + stage.tenants.length,
+ 0
+ )
+ return (
+
+ }
+ sx={{ borderBottom: '1px solid', borderColor: 'divider' }}
+ >
+ {[
+ { label: 'Baseline', value: row.templateName },
+ { label: 'Description', value: row.description },
+ { label: 'Standards', value: row.standardsCount },
+ { label: 'Remediation', value: row.remediationPosture },
+ {
+ label: 'Scheduled Runs',
+ value: row.disableScheduledRuns ? 'Disabled' : 'Enabled',
+ },
+ {
+ label: 'Last Updated',
+ value: row.updatedAt
+ ? parseCippDate(row.updatedAt).toLocaleString()
+ : 'N/A',
+ },
+ { label: 'Updated By', value: row.updatedBy },
+ ].map(({ label, value }) => (
+
+
+ {label}
+
+
+ {value ?? 'N/A'}
+
+
+ ))}
+
+
+
+ Stage Progress
+
+ {/* The offcanvas renders with an empty row until one is selected. */}
+ {occupancy.map((stage) => (
+
+
+
+
+ {stage.tenants.length} tenant
+ {stage.tenants.length === 1 ? '' : 's'} -{' '}
+ {stage.standardsCount} standard
+ {stage.standardsCount === 1 ? '' : 's'}
+
+
+
+ {stage.tenants.length > 0 && (
+
+ {stage.tenants.join(', ')}
+
+ )}
+ {stage.nextAdvanceAt && (
+
+ Next time-based advance out of this stage:{' '}
+ {parseCippDate(stage.nextAdvanceAt).toLocaleDateString()}
+
+ )}
+ {/* Why tenants in this stage have not advanced: the NEXT stage's
+ graduation conditions. */}
+ {stage.stage < occupancy.length &&
+ (row.stages ?? [])[stage.stage] && (
+
+ Tenants advance when{' '}
+ {describeStageConditions(row.stages[stage.stage])}.
+
+ )}
+
+ ))}
+
+ Assigned Tenants
+
+
+ {(row.assignedTenants ?? []).map((assigned) => (
+
+ ))}
+
+
+
+ );
+ },
+ }
+
+ return (
+
+
+
+
+ }
+ >
+ Add Baseline
+
+ setCatalogVisible(true)}
+ startIcon={
+
+
+
+ }
+ >
+ Browse Catalog
+
+
+
+
+ }
+ >
+ Migrate from Standards
+
+ setMigrateVisible(false)}
+ size="lg"
+ footer={
+
+
+ migrateCommit.mutate({
+ url: '/api/ExecBaselineMigrate',
+ data: {
+ action: 'commit',
+ templateIds: migrateSelected,
+ reportOnly: migrateReportOnly,
+ addDetectStandards: migrateAddDetect,
+ },
+ })
+ }
+ >
+ Migrate {migrateSelected.length} template
+ {migrateSelected.length === 1 ? '' : 's'}
+
+ setMigrateVisible(false)}
+ >
+ Close
+
+
+ }
+ >
+
+
+ Converts your classic Standards templates (including drift
+ templates) into baselines. The originals are never modified,
+ but while the Baselines feature is enabled the classic
+ Standards and Drift pages and their scheduled runs are turned
+ off - only one engine manages your tenants at a time.
+
+
+ setMigrateReportOnly(event.target.checked)
+ }
+ />
+ }
+ label="Import everything as report-only (recommended) - re-enable auto-remediation per standard once you have reviewed the results"
+ />
+
+ setMigrateAddDetect(event.target.checked)
+ }
+ />
+ }
+ label="Migrated drift templates should also alert on Intune and Conditional Access policies that were not created from a template"
+ />
+
+ {migratePreview.isPending && (
+
+
+
+ )}
+ {!migratePreview.isPending && migrationTemplates.length === 0 && (
+
+ No classic Standards templates were found to migrate.
+
+ )}
+
+ {migrationTemplates.map((template) => {
+ const selectable = ['Ready', 'WillUpdate'].includes(
+ template.status
+ )
+ const chip =
+ migrateStatusChip[template.status] ??
+ migrateStatusChip.Ready
+ return (
+
+
+ setMigrateSelected((prev) =>
+ prev.includes(template.v2Guid)
+ ? prev.filter((id) => id !== template.v2Guid)
+ : [...prev, template.v2Guid]
+ )
+ }
+ sx={{ mt: 0.5 }}
+ />
+
+
+ {template.templateName || '(unnamed template)'}
+
+ {template.type === 'drift' && (
+
+ )}
+
+
+ {template.standardsCount} standard
+ {template.standardsCount === 1 ? '' : 's'}
+
+
+ }
+ secondary={
+
+ {(template.tenants ?? []).length > 0 && (
+
+ Tenants: {(template.tenants ?? []).join(', ')}
+
+ )}
+ {template.detail && (
+
+ {template.detail}
+
+ )}
+ {(template.warnings ?? []).map((warning) => (
+
+ {warning}
+
+ ))}
+
+ }
+ />
+
+ );
+ })}
+
+
+
+ setCatalogVisible(false)}
+ size="xl"
+ footer={
+
+ setCatalogVisible(false)}
+ >
+ Close
+
+
+ }
+ >
+
+
+
+
+ >
+ }
+ simpleColumns={[
+ 'baselineName',
+ 'description',
+ 'standardsCount',
+ 'stageNames',
+ 'assignedTenants',
+ 'remediationPosture',
+ 'updatedAt',
+ 'updatedBy',
+ ]}
+ queryKey="ListBaselines-table"
+ />
+ );
+}
+
+Page.getLayout = (page) => (
+
+ {page}
+
+)
+
+export default Page
+
diff --git a/src/pages/tenant/conditional/deploy-vacation/add.jsx b/src/pages/tenant/conditional/deploy-vacation/add.jsx
deleted file mode 100644
index 8ca5bbd67cc5..000000000000
--- a/src/pages/tenant/conditional/deploy-vacation/add.jsx
+++ /dev/null
@@ -1,171 +0,0 @@
-import React from "react";
-import { Box, Divider, Stack, Typography } from "@mui/material";
-import { Grid } from "@mui/system";
-import CippFormPage from "../../../../components/CippFormPages/CippFormPage";
-import { Layout as DashboardLayout } from "../../../../layouts/index.js";
-import { useForm, useWatch } from "react-hook-form";
-import CippFormComponent from "../../../../components/CippComponents/CippFormComponent";
-import { CippFormUserSelector } from "../../../../components/CippComponents/CippFormUserSelector";
-import { CippFormTenantSelector } from "../../../../components/CippComponents/CippFormTenantSelector";
-
-const Page = () => {
- const formControl = useForm({
- mode: "onChange",
- defaultValues: {
- vacation: true,
- },
- });
-
- // Watch the selected tenant to update dependent fields
- const selectedTenant = useWatch({ control: formControl.control, name: "tenantFilter" });
- const tenantDomain = selectedTenant?.value || selectedTenant;
-
- return (
- <>
- {
- const shippedValues = {
- tenantFilter: values.tenantFilter?.value || values.tenantFilter,
- Users: values.Users,
- PolicyId: values.PolicyId?.value,
- StartDate: values.startDate,
- EndDate: values.endDate,
- vacation: true,
- };
- return shippedValues;
- }}
- >
-
-
- Vacation mode adds scheduled tasks to add and remove users from Conditional Access (CA)
- exclusions for a specific period of time. Select the CA policy and the date range.
-
-
-
-
-
-
-
-
-
- {/* User Selector */}
-
-
-
-
- {/* Conditional Access Policy Selector */}
-
- `${option.displayName}`,
- valueField: "id",
- showRefresh: true,
- }
- : null
- }
- multiple={false}
- formControl={formControl}
- validators={{
- validate: (option) => {
- if (!option?.value) {
- return "Picking a policy is required";
- }
- return true;
- },
- }}
- required={true}
- disabled={!tenantDomain}
- />
-
-
- {/* Start Date Picker */}
-
- {
- if (!value) {
- return "Start date is required";
- }
- return true;
- },
- }}
- />
-
-
- {/* End Date Picker */}
-
- {
- const startDate = formControl.getValues("startDate");
- if (!value) {
- return "End date is required";
- }
- if (startDate && value && new Date(value * 1000) < new Date(startDate * 1000)) {
- return "End date must be after start date";
- }
- return true;
- },
- }}
- />
-
-
-
-
- >
- );
-};
-
-Page.getLayout = (page) => {page};
-
-export default Page;
diff --git a/src/pages/tenant/conditional/deploy-vacation/index.js b/src/pages/tenant/conditional/deploy-vacation/index.js
deleted file mode 100644
index cce34f8d2dc7..000000000000
--- a/src/pages/tenant/conditional/deploy-vacation/index.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import { Layout as DashboardLayout } from "../../../../layouts/index.js";
-import { Alert, Box, Button } from "@mui/material";
-import Link from "next/link";
-
-const Page = () => {
- return (
-
-
- Vacation Mode has moved to{" "}
- Identity Management → Administration → Vacation Mode.
-
-
- Go to Vacation Mode
-
-
- );
-};
-
-Page.getLayout = (page) => {page};
-
-export default Page;
diff --git a/src/pages/tenant/conditional/list-named-locations/add.jsx b/src/pages/tenant/conditional/list-named-locations/add.jsx
index e7ba7ebe610e..93ce597e0799 100644
--- a/src/pages/tenant/conditional/list-named-locations/add.jsx
+++ b/src/pages/tenant/conditional/list-named-locations/add.jsx
@@ -2,7 +2,7 @@ import React from "react";
import { Typography } from "@mui/material";
import { Grid } from "@mui/system";
import { useForm } from "react-hook-form";
-import { Layout as DashboardLayout } from "../../../../layouts/index.js";
+import { Layout as DashboardLayout } from "../../../../layouts/index";
import CippFormPage from "../../../../components/CippFormPages/CippFormPage";
import CippFormComponent from "../../../../components/CippComponents/CippFormComponent";
import { CippFormTenantSelector } from "../../../../components/CippComponents/CippFormTenantSelector";
diff --git a/src/pages/tenant/conditional/list-named-locations/index.js b/src/pages/tenant/conditional/list-named-locations/index.js
deleted file mode 100644
index 20e6ca12d03e..000000000000
--- a/src/pages/tenant/conditional/list-named-locations/index.js
+++ /dev/null
@@ -1,263 +0,0 @@
-import { Layout as DashboardLayout } from "../../../../layouts/index.js";
-import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx";
-import { Button } from "@mui/material";
-import Link from "next/link";
-import {
- MinusIcon,
- PlusIcon,
- PencilIcon,
- ShieldCheckIcon,
- ShieldExclamationIcon,
- TrashIcon,
-} from "@heroicons/react/24/outline";
-import { LocationOn } from "@mui/icons-material";
-import countryList from "../../../../data/countryList.json";
-
-const Page = () => {
- const pageTitle = "Named Locations";
-
- const actions = [
- {
- label: "Rename named location",
- type: "POST",
- url: "/api/ExecNamedLocation",
- icon: ,
- data: {
- namedLocationId: "id",
- change: "!rename",
- },
- fields: [{ type: "textField", name: "input", label: "New Name" }],
- confirmText: "Enter the new name for this named location.",
- },
- {
- label: "Mark as Trusted",
- type: "POST",
- url: "/api/ExecNamedLocation",
- icon: ,
- data: {
- namedLocationId: "id",
- change: "!setTrusted",
- },
- confirmText: "Are you sure you want to mark this IP location as trusted?",
- condition: (row) =>
- row["@odata.type"] == "#microsoft.graph.ipNamedLocation" && !row.isTrusted,
- },
- {
- label: "Mark as Untrusted",
- type: "POST",
- url: "/api/ExecNamedLocation",
- icon: ,
- data: {
- namedLocationId: "id",
- change: "!setUntrusted",
- },
- confirmText: "Are you sure you want to mark this IP location as untrusted?",
- condition: (row) => row["@odata.type"] == "#microsoft.graph.ipNamedLocation" && row.isTrusted,
- },
- {
- label: "Add location to named location",
- type: "POST",
- url: "/api/ExecNamedLocation",
- icon: ,
- data: {
- namedLocationId: "id",
- change: "!addLocation",
- },
- fields: [
- {
- type: "autoComplete",
- name: "input",
- label: "Country",
- validators: {
- required: { value: true, message: "Please select a country" },
- },
- options: (row) => {
- const existingCountries = row?.countriesAndRegions || [];
- return countryList
- .filter(({ Code }) => !existingCountries.includes(Code))
- .map(({ Code, Name }) => ({
- value: Code,
- label: `${Name} (${Code})`,
- }));
- },
- },
- ],
- confirmText: "Select a country to add to this named location.",
- condition: (row) => row["@odata.type"] == "#microsoft.graph.countryNamedLocation",
- },
- {
- label: "Remove location from named location",
- type: "POST",
- url: "/api/ExecNamedLocation",
- icon: ,
- data: {
- namedLocationId: "id",
- change: "!removeLocation",
- },
- fields: [
- {
- type: "autoComplete",
- name: "input",
- label: "Country",
- multiple: true,
- validators: {
- required: { value: true, message: "Please select at least one country" },
- validate: (value, formValues, row) => {
- const totalCountries = row?.countriesAndRegions?.length || 0;
- const selectedCount = Array.isArray(value) ? value.length : value ? 1 : 0;
- if (selectedCount >= totalCountries) {
- return "You must leave at least one country in the named location";
- }
- return true;
- },
- },
- options: (row) => {
- const currentCountries = row?.countriesAndRegions || [];
- return currentCountries.map((code) => {
- const country = countryList.find((c) => c.Code === code);
- return {
- value: code,
- label: country ? `${country.Name} (${code})` : code,
- };
- });
- },
- },
- ],
- confirmText: "Select countries to remove from this named location.",
- condition: (row) =>
- row["@odata.type"] == "#microsoft.graph.countryNamedLocation" &&
- (row.countriesAndRegions?.length || 0) > 1,
- },
- {
- label: "Add IP to named location",
- type: "POST",
- url: "/api/ExecNamedLocation",
- icon: ,
- data: {
- namedLocationId: "id",
- change: "!addIp",
- },
- fields: [
- {
- type: "textField",
- name: "input",
- label: "IP",
- validators: {
- required: { value: true, message: "IP address is required" },
- validate: (value) => {
- if (!value) return true;
- // IPv4 CIDR pattern
- const ipv4Cidr =
- /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\/(\d{1,3})$/;
- // IPv6 CIDR pattern (simplified - covers most common formats)
- const ipv6Cidr =
- /^(?:(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,7}:|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:(?::[0-9a-fA-F]{1,4}){1,6}|:(?::[0-9a-fA-F]{1,4}){1,7}|::)\/(\d{1,3})$/;
-
- const ipv4Match = value.match(ipv4Cidr);
- const ipv6Match = value.match(ipv6Cidr);
-
- if (ipv4Match) {
- const prefix = parseInt(ipv4Match[1], 10);
- if (prefix < 9 || prefix > 32) {
- return "CIDR prefix must be between /9 and /32 for IPv4";
- }
- return true;
- }
-
- if (ipv6Match) {
- const prefix = parseInt(ipv6Match[1], 10);
- if (prefix < 9 || prefix > 128) {
- return "CIDR prefix must be between /9 and /128 for IPv6";
- }
- return true;
- }
-
- return "Invalid CIDR format. Use IPv4 (e.g., 1.1.1.1/32) or IPv6 (e.g., 2001:db8::/32)";
- },
- },
- },
- ],
- confirmText: "Enter an IP in CIDR format, e.g., 1.1.1.1/32 or 2001:db8::/32.",
- condition: (row) => row["@odata.type"] == "#microsoft.graph.ipNamedLocation",
- },
- {
- label: "Remove IP from named location",
- type: "POST",
- url: "/api/ExecNamedLocation",
- icon: ,
- data: {
- namedLocationId: "id",
- change: "!removeIp",
- },
- fields: [
- {
- type: "autoComplete",
- name: "input",
- label: "IP",
- multiple: true,
- validators: {
- required: { value: true, message: "Please select at least one IP" },
- validate: (value, formValues, row) => {
- const totalIps = row?.ipRanges?.length || 0;
- const selectedCount = Array.isArray(value) ? value.length : value ? 1 : 0;
- if (selectedCount >= totalIps) {
- return "You must leave at least one IP in the named location";
- }
- return true;
- },
- },
- options: (row) => {
- const ipRanges = row?.ipRanges || [];
- return ipRanges.map((ip) => ({
- value: ip.cidrAddress,
- label: ip.cidrAddress,
- }));
- },
- },
- ],
- confirmText: "Select IPs to remove from this named location.",
- condition: (row) =>
- row["@odata.type"] == "#microsoft.graph.ipNamedLocation" &&
- (row.ipRanges?.length || 0) > 1,
- },
- {
- label: "Delete named location",
- type: "POST",
- url: "/api/ExecNamedLocation",
- icon: ,
- data: {
- namedLocationId: "id",
- change: "!delete",
- },
- confirmText:
- "Are you sure you want to delete this named location? This action cannot be undone.",
- color: "error",
- },
- ];
-
- return (
-
- }>
- Add Named Location
-
- >
- }
- simpleColumns={[
- "displayName",
- "includeUnknownCountriesAndRegions",
- "isTrusted",
- "rangeOrLocation",
- "modifiedDateTime",
- ]}
- />
- );
-};
-
-Page.getLayout = (page) => {page};
-
-export default Page;
diff --git a/src/pages/tenant/conditional/list-named-locations/index.jsx b/src/pages/tenant/conditional/list-named-locations/index.jsx
new file mode 100644
index 000000000000..9586a29ac977
--- /dev/null
+++ b/src/pages/tenant/conditional/list-named-locations/index.jsx
@@ -0,0 +1,255 @@
+import { Layout as DashboardLayout } from "../../../../layouts/index";
+import { CippIcons } from "../../../../utils/icon-registry"
+import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx";
+import { Button } from "@mui/material";
+import Link from "next/link";
+import countryList from "../../../../data/countryList.json";
+
+const Page = () => {
+ const pageTitle = "Named Locations";
+
+ const actions = [
+ {
+ label: "Rename named location",
+ type: "POST",
+ url: "/api/ExecNamedLocation",
+ icon: ,
+ data: {
+ namedLocationId: "id",
+ change: "!rename",
+ },
+ fields: [{ type: "textField", name: "input", label: "New Name" }],
+ confirmText: "Enter the new name for this named location.",
+ },
+ {
+ label: "Mark as Trusted",
+ type: "POST",
+ url: "/api/ExecNamedLocation",
+ icon: ,
+ data: {
+ namedLocationId: "id",
+ change: "!setTrusted",
+ },
+ confirmText: "Are you sure you want to mark this IP location as trusted?",
+ condition: (row) =>
+ row["@odata.type"] == "#microsoft.graph.ipNamedLocation" && !row.isTrusted,
+ },
+ {
+ label: "Mark as Untrusted",
+ type: "POST",
+ url: "/api/ExecNamedLocation",
+ icon: ,
+ data: {
+ namedLocationId: "id",
+ change: "!setUntrusted",
+ },
+ confirmText: "Are you sure you want to mark this IP location as untrusted?",
+ condition: (row) => row["@odata.type"] == "#microsoft.graph.ipNamedLocation" && row.isTrusted,
+ },
+ {
+ label: "Add location to named location",
+ type: "POST",
+ url: "/api/ExecNamedLocation",
+ icon: ,
+ data: {
+ namedLocationId: "id",
+ change: "!addLocation",
+ },
+ fields: [
+ {
+ type: "autoComplete",
+ name: "input",
+ label: "Country",
+ validators: {
+ required: { value: true, message: "Please select a country" },
+ },
+ options: (row) => {
+ const existingCountries = row?.countriesAndRegions || [];
+ return countryList
+ .filter(({ Code }) => !existingCountries.includes(Code))
+ .map(({ Code, Name }) => ({
+ value: Code,
+ label: `${Name} (${Code})`,
+ }));
+ },
+ },
+ ],
+ confirmText: "Select a country to add to this named location.",
+ condition: (row) => row["@odata.type"] == "#microsoft.graph.countryNamedLocation",
+ },
+ {
+ label: "Remove location from named location",
+ type: "POST",
+ url: "/api/ExecNamedLocation",
+ icon: ,
+ data: {
+ namedLocationId: "id",
+ change: "!removeLocation",
+ },
+ fields: [
+ {
+ type: "autoComplete",
+ name: "input",
+ label: "Country",
+ multiple: true,
+ validators: {
+ required: { value: true, message: "Please select at least one country" },
+ validate: (value, formValues, row) => {
+ const totalCountries = row?.countriesAndRegions?.length || 0;
+ const selectedCount = Array.isArray(value) ? value.length : value ? 1 : 0;
+ if (selectedCount >= totalCountries) {
+ return "You must leave at least one country in the named location";
+ }
+ return true;
+ },
+ },
+ options: (row) => {
+ const currentCountries = row?.countriesAndRegions || [];
+ return currentCountries.map((code) => {
+ const country = countryList.find((c) => c.Code === code);
+ return {
+ value: code,
+ label: country ? `${country.Name} (${code})` : code,
+ };
+ });
+ },
+ },
+ ],
+ confirmText: "Select countries to remove from this named location.",
+ condition: (row) =>
+ row["@odata.type"] == "#microsoft.graph.countryNamedLocation" &&
+ (row.countriesAndRegions?.length || 0) > 1,
+ },
+ {
+ label: "Add IP to named location",
+ type: "POST",
+ url: "/api/ExecNamedLocation",
+ icon: ,
+ data: {
+ namedLocationId: "id",
+ change: "!addIp",
+ },
+ fields: [
+ {
+ type: "textField",
+ name: "input",
+ label: "IP",
+ validators: {
+ required: { value: true, message: "IP address is required" },
+ validate: (value) => {
+ if (!value) return true;
+ // IPv4 CIDR pattern
+ const ipv4Cidr =
+ /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\/(\d{1,3})$/;
+ // IPv6 CIDR pattern (simplified - covers most common formats)
+ const ipv6Cidr =
+ /^(?:(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,7}:|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:(?::[0-9a-fA-F]{1,4}){1,6}|:(?::[0-9a-fA-F]{1,4}){1,7}|::)\/(\d{1,3})$/;
+
+ const ipv4Match = value.match(ipv4Cidr);
+ const ipv6Match = value.match(ipv6Cidr);
+
+ if (ipv4Match) {
+ const prefix = parseInt(ipv4Match[1], 10);
+ if (prefix < 9 || prefix > 32) {
+ return "CIDR prefix must be between /9 and /32 for IPv4";
+ }
+ return true;
+ }
+
+ if (ipv6Match) {
+ const prefix = parseInt(ipv6Match[1], 10);
+ if (prefix < 9 || prefix > 128) {
+ return "CIDR prefix must be between /9 and /128 for IPv6";
+ }
+ return true;
+ }
+
+ return "Invalid CIDR format. Use IPv4 (e.g., 1.1.1.1/32) or IPv6 (e.g., 2001:db8::/32)";
+ },
+ },
+ },
+ ],
+ confirmText: "Enter an IP in CIDR format, e.g., 1.1.1.1/32 or 2001:db8::/32.",
+ condition: (row) => row["@odata.type"] == "#microsoft.graph.ipNamedLocation",
+ },
+ {
+ label: "Remove IP from named location",
+ type: "POST",
+ url: "/api/ExecNamedLocation",
+ icon: ,
+ data: {
+ namedLocationId: "id",
+ change: "!removeIp",
+ },
+ fields: [
+ {
+ type: "autoComplete",
+ name: "input",
+ label: "IP",
+ multiple: true,
+ validators: {
+ required: { value: true, message: "Please select at least one IP" },
+ validate: (value, formValues, row) => {
+ const totalIps = row?.ipRanges?.length || 0;
+ const selectedCount = Array.isArray(value) ? value.length : value ? 1 : 0;
+ if (selectedCount >= totalIps) {
+ return "You must leave at least one IP in the named location";
+ }
+ return true;
+ },
+ },
+ options: (row) => {
+ const ipRanges = row?.ipRanges || [];
+ return ipRanges.map((ip) => ({
+ value: ip.cidrAddress,
+ label: ip.cidrAddress,
+ }));
+ },
+ },
+ ],
+ confirmText: "Select IPs to remove from this named location.",
+ condition: (row) =>
+ row["@odata.type"] == "#microsoft.graph.ipNamedLocation" &&
+ (row.ipRanges?.length || 0) > 1,
+ },
+ {
+ label: "Delete named location",
+ type: "POST",
+ url: "/api/ExecNamedLocation",
+ icon: ,
+ data: {
+ namedLocationId: "id",
+ change: "!delete",
+ },
+ confirmText:
+ "Are you sure you want to delete this named location? This action cannot be undone.",
+ color: "error",
+ },
+ ];
+
+ return (
+
+ }>
+ Add Named Location
+
+ >
+ }
+ simpleColumns={[
+ "displayName",
+ "includeUnknownCountriesAndRegions",
+ "isTrusted",
+ "rangeOrLocation",
+ "modifiedDateTime",
+ ]}
+ />
+ );
+};
+
+Page.getLayout = (page) => {page};
+
+export default Page;
diff --git a/src/pages/tenant/conditional/list-policies/edit.jsx b/src/pages/tenant/conditional/list-policies/edit.jsx
index 156cd39f12b2..4f45fd60487f 100644
--- a/src/pages/tenant/conditional/list-policies/edit.jsx
+++ b/src/pages/tenant/conditional/list-policies/edit.jsx
@@ -1,23 +1,59 @@
-import React, { useEffect, useState } from "react";
+import React, { useEffect, useMemo, useState } from "react";
import { Alert, Box } from "@mui/material";
import { useForm } from "react-hook-form";
import { useRouter } from "next/router";
-import { Layout as DashboardLayout } from "../../../../layouts/index.js";
+import { Layout as DashboardLayout } from "../../../../layouts/index";
import CippFormPage from "../../../../components/CippFormPages/CippFormPage";
import CippFormSkeleton from "../../../../components/CippFormPages/CippFormSkeleton";
-import { ApiGetCall } from "../../../../api/ApiCall";
+import { ApiGetCall, ApiPostCall } from "../../../../api/ApiCall";
import CippCAPolicyBuilder, {
+ directoryObjectLabel,
extractCAPolicyJSON,
} from "../../../../components/CippComponents/CippCAPolicyBuilder";
import { useSettings } from "../../../../hooks/use-settings.js";
+// The assignment arrays Graph stores as object IDs. The special tokens (All, None,
+// GuestsOrExternalUsers) share these arrays and are left as they are.
+const DIRECTORY_FIELDS = ["includeUsers", "excludeUsers", "includeGroups", "excludeGroups"];
+const GUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+// directoryObjects/getByIds accepts at most 1000 IDs per call
+const GET_BY_IDS_LIMIT = 1000;
+
+const collectDirectoryIds = (policy) => {
+ const users = policy?.conditions?.users ?? {};
+ const ids = DIRECTORY_FIELDS.flatMap((field) => users[field] ?? []).filter((id) =>
+ GUID_PATTERN.test(id)
+ );
+ return [...new Set(ids)];
+};
+
+// Swap each object ID for a { label, value } option so the editor shows names. The value stays
+// the ID, which is what extractCAPolicyJSON sends back on save. An ID that did not resolve (a
+// user or group deleted since it was assigned) keeps the ID as its label.
+const labelDirectoryIds = (policy, names) => {
+ const users = policy?.conditions?.users;
+ if (!users) return policy;
+ const labelled = { ...users };
+ DIRECTORY_FIELDS.forEach((field) => {
+ if (Array.isArray(users[field])) {
+ labelled[field] = users[field].map((id) =>
+ GUID_PATTERN.test(id) ? { label: names[id] ?? id, value: id } : id
+ );
+ }
+ });
+ return { ...policy, conditions: { ...policy.conditions, users: labelled } };
+};
+
const EditCAPolicy = () => {
const router = useRouter();
const { id: policyId } = router.query;
const tenantFilter = useSettings()?.currentTenant;
const [policyData, setPolicyData] = useState(null);
+ // null while the policy's user and group IDs are being resolved to names; {} when it has none
+ const [directoryNames, setDirectoryNames] = useState(null);
const formControl = useForm({ mode: "onChange" });
+ const { mutateAsync: lookupDirectoryObjects } = ApiPostCall({});
// Fetch the current policies for this tenant
const policiesQuery = ApiGetCall({
@@ -27,14 +63,50 @@ const EditCAPolicy = () => {
});
useEffect(() => {
- if (policiesQuery.isSuccess && policiesQuery.data?.Results) {
- const match = policiesQuery.data.Results.find((p) => p.id === policyId);
- if (match?.rawjson) {
- const parsed = JSON.parse(match.rawjson);
- setPolicyData(parsed);
- }
+ if (!policiesQuery.isSuccess || !policiesQuery.data?.Results) return undefined;
+ const match = policiesQuery.data.Results.find((p) => p.id === policyId);
+ if (!match?.rawjson) return undefined;
+ const parsed = JSON.parse(match.rawjson);
+ setPolicyData(parsed);
+ setDirectoryNames(null);
+
+ const ids = collectDirectoryIds(parsed);
+ if (ids.length === 0) {
+ setDirectoryNames({});
+ return undefined;
+ }
+ const batches = [];
+ for (let i = 0; i < ids.length; i += GET_BY_IDS_LIMIT) {
+ batches.push({
+ tenantFilter,
+ ids: ids.slice(i, i + GET_BY_IDS_LIMIT),
+ $select: "id,displayName,userPrincipalName,mail",
+ });
}
- }, [policiesQuery.isSuccess, policiesQuery.data, policyId]);
+ let cancelled = false;
+ const names = {};
+ lookupDirectoryObjects({ url: "/api/ListDirectoryObjects", bulkRequest: true, data: batches })
+ .then((pages) => {
+ pages.forEach((page) => {
+ (page?.value ?? []).forEach((obj) => {
+ if (obj?.id) names[obj.id] = directoryObjectLabel(obj);
+ });
+ });
+ })
+ // A failed lookup is not fatal: the editor falls back to showing the raw IDs.
+ .catch(() => {})
+ .finally(() => {
+ if (!cancelled) setDirectoryNames(names);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [policiesQuery.isSuccess, policiesQuery.data, policyId, tenantFilter, lookupDirectoryObjects]);
+
+ const existingPolicy = useMemo(
+ () => (policyData && directoryNames ? labelDirectoryIds(policyData, directoryNames) : null),
+ [policyData, directoryNames]
+ );
const dataFormatter = (values) => {
const cleaned = extractCAPolicyJSON(values);
@@ -56,14 +128,18 @@ const EditCAPolicy = () => {
formPageType="Edit"
>
- {policiesQuery.isLoading ? (
+ {policiesQuery.isLoading || (policyData && !existingPolicy) ? (
) : policiesQuery.isError ? (
Error loading policies.
) : !policyData ? (
Policy not found for ID: {policyId}
) : (
-
+
)}
diff --git a/src/pages/tenant/conditional/list-policies/index.js b/src/pages/tenant/conditional/list-policies/index.js
deleted file mode 100644
index 1e85c99b3ebc..000000000000
--- a/src/pages/tenant/conditional/list-policies/index.js
+++ /dev/null
@@ -1,195 +0,0 @@
-import { Layout as DashboardLayout } from "../../../../layouts/index.js";
-import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx";
-import {
- Block,
- Check,
- Delete,
- MenuBook,
- Visibility,
- Edit,
- VerifiedUser,
-} from "@mui/icons-material";
-import { Box } from "@mui/material";
-import CippJsonView from "../../../../components/CippFormPages/CippJSONView";
-import { CippCADeployDrawer } from "../../../../components/CippComponents/CippCADeployDrawer";
-import { CippApiLogsDrawer } from "../../../../components/CippComponents/CippApiLogsDrawer";
-import { PermissionButton } from "../../../../utils/permissions";
-import { useSettings } from "../../../../hooks/use-settings.js";
-
-// Page Component
-const Page = () => {
- const pageTitle = "Conditional Access";
- const apiUrl = "/api/ListConditionalAccessPolicies";
- const cardButtonPermissions = ["Tenant.ConditionalAccess.ReadWrite"];
- const tenant = useSettings().currentTenant;
-
- // Actions configuration
- const actions = [
- {
- label: "Edit Policy",
- link: "/tenant/conditional/list-policies/edit?id=[id]",
- icon: ,
- color: "info",
- hideBulk: true,
- },
- {
- label: "Create template based on policy",
- type: "POST",
- url: "/api/AddCATemplate",
- dataFunction: (data) => {
- if (Array.isArray(data)) {
- return data.map((item) => JSON.parse(item.rawjson));
- }
- return JSON.parse(data.rawjson);
- },
- hideBulk: true,
- confirmText: `Are you sure you want to create a template based on "[displayName]"?`,
- icon: ,
- color: "info",
- },
- {
- label: "Change Display Name",
- type: "POST",
- url: "/api/EditCAPolicy",
- data: {
- GUID: "id",
- },
- confirmText: `What do you want to change the display name of "[displayName]" to?`,
- icon: ,
- color: "info",
- hideBulk: true,
- fields: [
- {
- type: "textField",
- name: "newDisplayName",
- label: "New Display Name",
- required: true,
- validate: (value) => {
- if (!value) {
- return "Display name is required.";
- }
- return true;
- },
- },
- ],
- },
- {
- label: "Enable policy",
- type: "POST",
- url: "/api/EditCAPolicy",
- data: {
- GUID: "id",
- State: "!Enabled",
- },
- confirmText: `Are you sure you want to enable "[displayName]"?`,
- condition: (row) => row.state !== "enabled",
- icon: ,
- color: "info",
- },
- {
- label: "Disable policy",
- type: "POST",
- url: "/api/EditCAPolicy",
- data: {
- GUID: "id",
- State: "!Disabled",
- },
- confirmText: `Are you sure you want to disable "[displayName]"?`,
- condition: (row) => row.state !== "disabled",
- icon: ,
- color: "info",
- },
- {
- label: "Set policy to report only",
- type: "POST",
- url: "/api/EditCAPolicy",
- data: {
- GUID: "id",
- State: "!enabledForReportingButNotEnforced",
- },
- confirmText: `Are you sure you want to set "[displayName]" to report only?`,
- condition: (row) => row.state !== "enabledForReportingButNotEnforced",
- icon: ,
- color: "info",
- },
- {
- label: "Add service provider exception to policy",
- type: "POST",
- url: "/api/ExecCAServiceExclusion",
- data: {
- GUID: "id",
- },
- confirmText: `Are you sure you want to add the service provider exception to "[displayName]"?`,
- icon: ,
- color: "warning",
- },
- {
- label: "Delete policy",
- type: "POST",
- url: "/api/RemoveCAPolicy",
- data: {
- GUID: "id",
- },
- confirmText: `Are you sure you want to delete "[displayName]"?`,
- icon: ,
- color: "danger",
- },
- ];
-
- // Off-canvas configuration
- const offCanvas = {
- children: (row) => (
-
-
-
- ),
- size: "xl",
- };
-
- // Columns for CippTablePage
- const simpleColumns = [
- "Tenant",
- "displayName",
- "state",
- "modifiedDateTime",
- "clientAppTypes",
- "includePlatforms",
- "excludePlatforms",
- "includeLocations",
- "excludeLocations",
- "includeUsers",
- "excludeUsers",
- "includeGroups",
- "excludeGroups",
- "includeApplications",
- "excludeApplications",
- "grantControlsOperator",
- "builtInControls",
- ];
-
- return (
-
-
-
-
- }
- title={pageTitle}
- apiUrl={apiUrl}
- apiDataKey="Results"
- actions={actions}
- offCanvas={offCanvas}
- simpleColumns={simpleColumns}
- />
- );
-};
-
-Page.getLayout = (page) => {page};
-export default Page;
diff --git a/src/pages/tenant/conditional/list-policies/index.jsx b/src/pages/tenant/conditional/list-policies/index.jsx
new file mode 100644
index 000000000000..f8b213d2c8bf
--- /dev/null
+++ b/src/pages/tenant/conditional/list-policies/index.jsx
@@ -0,0 +1,189 @@
+import { Layout as DashboardLayout } from "../../../../layouts/index";
+import { CippIcons } from "../../../../utils/icon-registry"
+import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx";
+import { Box } from "@mui/material";
+import CippJsonView from "../../../../components/CippFormPages/CippJSONView";
+import { CippCADeployDrawer } from "../../../../components/CippComponents/CippCADeployDrawer";
+import { CippApiLogsDrawer } from "../../../../components/CippComponents/CippApiLogsDrawer";
+import { PermissionButton } from "../../../../utils/permissions";
+import { useSettings } from "../../../../hooks/use-settings.js";
+
+// Page Component
+const Page = () => {
+ const pageTitle = "Conditional Access";
+ const apiUrl = "/api/ListConditionalAccessPolicies";
+ const cardButtonPermissions = ["Tenant.ConditionalAccess.ReadWrite"];
+ const tenant = useSettings().currentTenant;
+
+ // Actions configuration
+ const actions = [
+ {
+ label: "Edit Policy",
+ link: "/tenant/conditional/list-policies/edit?id=[id]",
+ pinned: true,
+ icon: ,
+ color: "info",
+ hideBulk: true,
+ },
+ {
+ label: "Create template based on policy",
+ type: "POST",
+ url: "/api/AddCATemplate",
+ dataFunction: (data) => {
+ if (Array.isArray(data)) {
+ return data.map((item) => JSON.parse(item.rawjson));
+ }
+ return JSON.parse(data.rawjson);
+ },
+ hideBulk: true,
+ confirmText: `Are you sure you want to create a template based on "[displayName]"?`,
+ icon: ,
+ color: "info",
+ },
+ {
+ label: "Change Display Name",
+ type: "POST",
+ url: "/api/EditCAPolicy",
+ data: {
+ GUID: "id",
+ },
+ confirmText: `What do you want to change the display name of "[displayName]" to?`,
+ icon: ,
+ color: "info",
+ hideBulk: true,
+ fields: [
+ {
+ type: "textField",
+ name: "newDisplayName",
+ label: "New Display Name",
+ required: true,
+ validate: (value) => {
+ if (!value) {
+ return "Display name is required.";
+ }
+ return true;
+ },
+ },
+ ],
+ },
+ {
+ label: "Enable Policy",
+ type: "POST",
+ url: "/api/EditCAPolicy",
+ data: {
+ GUID: "id",
+ State: "!Enabled",
+ },
+ confirmText: `Are you sure you want to enable "[displayName]"?`,
+ condition: (row) => row.state !== "enabled",
+ icon: ,
+ color: "info",
+ },
+ {
+ label: "Disable Policy",
+ type: "POST",
+ url: "/api/EditCAPolicy",
+ data: {
+ GUID: "id",
+ State: "!Disabled",
+ },
+ confirmText: `Are you sure you want to disable "[displayName]"?`,
+ condition: (row) => row.state !== "disabled",
+ icon: ,
+ color: "info",
+ },
+ {
+ label: "Set policy to report only",
+ type: "POST",
+ url: "/api/EditCAPolicy",
+ data: {
+ GUID: "id",
+ State: "!enabledForReportingButNotEnforced",
+ },
+ confirmText: `Are you sure you want to set "[displayName]" to report only?`,
+ condition: (row) => row.state !== "enabledForReportingButNotEnforced",
+ icon: ,
+ color: "info",
+ },
+ {
+ label: "Add service provider exception to policy",
+ type: "POST",
+ url: "/api/ExecCAServiceExclusion",
+ data: {
+ GUID: "id",
+ },
+ confirmText: `Are you sure you want to add the service provider exception to "[displayName]"?`,
+ icon: ,
+ color: "warning",
+ },
+ {
+ label: "Delete Policy",
+ type: "POST",
+ url: "/api/RemoveCAPolicy",
+ data: {
+ GUID: "id",
+ },
+ confirmText: `Are you sure you want to delete "[displayName]"?`,
+ icon: ,
+ color: "danger",
+ },
+ ];
+
+ // Off-canvas configuration
+ const offCanvas = {
+ children: (row) => (
+
+
+
+ ),
+ size: "xl",
+ };
+
+ // Columns for CippTablePage
+ const simpleColumns = [
+ "Tenant",
+ "displayName",
+ "state",
+ "modifiedDateTime",
+ "clientAppTypes",
+ "includePlatforms",
+ "excludePlatforms",
+ "includeLocations",
+ "excludeLocations",
+ "includeUsers",
+ "excludeUsers",
+ "includeGroups",
+ "excludeGroups",
+ "includeApplications",
+ "excludeApplications",
+ "grantControlsOperator",
+ "builtInControls",
+ ];
+
+ return (
+
+
+
+
+ }
+ title={pageTitle}
+ apiUrl={apiUrl}
+ apiData={{ manualPagination: true }}
+ apiDataKey="Results"
+ actions={actions}
+ offCanvas={offCanvas}
+ simpleColumns={simpleColumns}
+ />
+ );
+};
+
+Page.getLayout = (page) => {page};
+export default Page;
diff --git a/src/pages/tenant/conditional/list-template/create.jsx b/src/pages/tenant/conditional/list-template/create.jsx
index 4e94e4327498..a390c7615998 100644
--- a/src/pages/tenant/conditional/list-template/create.jsx
+++ b/src/pages/tenant/conditional/list-template/create.jsx
@@ -1,7 +1,7 @@
import React from "react";
import { Box } from "@mui/material";
import { useForm } from "react-hook-form";
-import { Layout as DashboardLayout } from "../../../../layouts/index.js";
+import { Layout as DashboardLayout } from "../../../../layouts/index";
import CippFormPage from "../../../../components/CippFormPages/CippFormPage";
import CippCAPolicyBuilder, { extractCAPolicyJSON } from "../../../../components/CippComponents/CippCAPolicyBuilder";
diff --git a/src/pages/tenant/conditional/list-template/edit.jsx b/src/pages/tenant/conditional/list-template/edit.jsx
index 85cd5bbc6397..d5c16f3547cd 100644
--- a/src/pages/tenant/conditional/list-template/edit.jsx
+++ b/src/pages/tenant/conditional/list-template/edit.jsx
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
import { Alert, Box, Typography, ToggleButtonGroup, ToggleButton } from "@mui/material";
import { useForm } from "react-hook-form";
import { useRouter } from "next/router";
-import { Layout as DashboardLayout } from "../../../../layouts/index.js";
+import { Layout as DashboardLayout } from "../../../../layouts/index";
import CippFormPage from "../../../../components/CippFormPages/CippFormPage";
import CippFormSkeleton from "../../../../components/CippFormPages/CippFormSkeleton";
import { ApiGetCall } from "../../../../api/ApiCall";
@@ -134,7 +134,9 @@ const EditCATemplate = () => {
return (
{
- const pageTitle = "Available Conditional Access Templates";
- const [deployDrawerOpen, setDeployDrawerOpen] = useState(false);
- const [selectedTemplateId, setSelectedTemplateId] = useState(null);
- const tenant = useSettings().currentTenant;
-
- const integrations = ApiGetCall({
- url: "/api/ListExtensionsConfig",
- queryKey: "Integrations",
- refetchOnMount: false,
- refetchOnReconnect: false,
- });
-
- const handleDeployTemplate = (row) => {
- setSelectedTemplateId(row.GUID);
- setDeployDrawerOpen(true);
- };
- const actions = [
- {
- label: "Deploy Template",
- customFunction: handleDeployTemplate,
- noConfirm: true,
- icon: ,
- color: "success",
- },
- {
- label: "Edit Template",
- link: "/tenant/conditional/list-template/edit?GUID=[GUID]",
- icon: ,
- color: "info",
- },
- {
- label: "Add to package",
- type: "POST",
- url: "/api/ExecSetPackageTag",
- data: { GUID: "GUID" },
- fields: [
- {
- type: "textField",
- name: "Package",
- label: "Package Name",
- required: true,
- validators: {
- required: { value: true, message: "Package name is required" },
- },
- },
- ],
- confirmText: "Enter the package name to assign to the selected template(s).",
- multiPost: true,
- icon: ,
- color: "info",
- },
- {
- label: "Remove from package",
- type: "POST",
- url: "/api/ExecSetPackageTag",
- data: { GUID: "GUID", Remove: true },
- confirmText: "Are you sure you want to remove the selected template(s) from their package?",
- multiPost: true,
- icon: ,
- color: "warning",
- },
- {
- label: "Save to GitHub",
- type: "POST",
- url: "/api/ExecCommunityRepo",
- icon: ,
- data: {
- Action: "UploadTemplate",
- GUID: "GUID",
- },
- fields: [
- {
- label: "Repository",
- name: "FullName",
- type: "select",
- api: {
- url: "/api/ListCommunityRepos",
- data: {
- WriteAccess: true,
- },
- queryKey: "CommunityRepos-Write",
- dataKey: "Results",
- valueField: "FullName",
- labelField: "FullName",
- },
- multiple: false,
- creatable: false,
- required: true,
- validators: {
- required: { value: true, message: "This field is required" },
- },
- },
- {
- label: "Commit Message",
- placeholder: "Enter a commit message for adding this file to GitHub",
- name: "Message",
- type: "textField",
- multiline: true,
- required: true,
- rows: 4,
- },
- ],
- confirmText: "Are you sure you want to save this template to the selected repository?",
- condition: () => integrations.isSuccess && integrations?.data?.GitHub?.Enabled,
- },
- {
- label: "Delete Template",
- type: "POST",
- url: "/api/RemoveCATemplate",
- icon: ,
- data: { ID: "GUID" },
- confirmText: "Do you want to delete the template?",
- multiPost: false,
- },
- ];
-
- const offCanvas = {
- children: (row) => ,
- size: "xl",
- };
- return (
- <>
-
- }
- >
- Create Template
-
-
-
-
- }
- />
- setDeployDrawerOpen(false)}
- templateId={selectedTemplateId}
- />
- >
- );
-};
-
-Page.getLayout = (page) => {page};
-
-export default Page;
diff --git a/src/pages/tenant/conditional/list-template/index.jsx b/src/pages/tenant/conditional/list-template/index.jsx
new file mode 100644
index 000000000000..8d74a5ab5680
--- /dev/null
+++ b/src/pages/tenant/conditional/list-template/index.jsx
@@ -0,0 +1,185 @@
+import { Layout as DashboardLayout } from "../../../../layouts/index";
+import { CippIcons } from "../../../../utils/icon-registry"
+import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx";
+import { Button, Box } from "@mui/material";
+import CippJsonView from "../../../../components/CippFormPages/CippJSONView";
+import { ApiGetCall } from "../../../../api/ApiCall";
+import { CippPolicyImportDrawer } from "../../../../components/CippComponents/CippPolicyImportDrawer.jsx";
+import { CippCADeployDrawer } from "../../../../components/CippComponents/CippCADeployDrawer.jsx";
+import { CippApiLogsDrawer } from "../../../../components/CippComponents/CippApiLogsDrawer";
+import { PermissionButton } from "../../../../utils/permissions";
+import { useSettings } from "../../../../hooks/use-settings.js";
+import { useState } from "react";
+import Link from "next/link";
+
+const Page = () => {
+ const pageTitle = "Available Conditional Access Templates";
+ const [deployDrawerOpen, setDeployDrawerOpen] = useState(false);
+ const [selectedTemplateId, setSelectedTemplateId] = useState(null);
+ const tenant = useSettings().currentTenant;
+
+ const integrations = ApiGetCall({
+ url: "/api/ListExtensionsConfig",
+ queryKey: "Integrations",
+ refetchOnMount: false,
+ refetchOnReconnect: false,
+ });
+
+ const handleDeployTemplate = (row) => {
+ setSelectedTemplateId(row.GUID);
+ setDeployDrawerOpen(true);
+ };
+ const actions = [
+ {
+ label: "Deploy Template",
+ customFunction: handleDeployTemplate,
+ noConfirm: true,
+ icon: ,
+ color: "success",
+ },
+ {
+ label: "Edit Template",
+ link: "/tenant/conditional/list-template/edit?GUID=[GUID]",
+ pinned: true,
+ icon: ,
+ color: "info",
+ },
+ {
+ label: "Add to package",
+ type: "POST",
+ url: "/api/ExecSetPackageTag",
+ data: { GUID: "GUID" },
+ fields: [
+ {
+ type: "select",
+ name: "Package",
+ label: "Package Name",
+ required: true,
+ creatable: true,
+ validators: {
+ required: { value: true, message: "Package name is required" },
+ },
+ api: {
+ url: "/api/ListCATemplates?mode=Tag",
+ queryKey: "ListCATemplates-tag-autocomplete",
+ labelField: "label",
+ valueField: "value",
+ },
+ },
+ ],
+ confirmText: "Select an existing package, or type a new package name.",
+ multiPost: true,
+ icon: ,
+ color: "info",
+ },
+ {
+ label: "Remove from package",
+ type: "POST",
+ url: "/api/ExecSetPackageTag",
+ data: { GUID: "GUID", Remove: true },
+ confirmText: "Are you sure you want to remove the selected template(s) from their package?",
+ multiPost: true,
+ icon: ,
+ color: "warning",
+ },
+ {
+ label: "Save to GitHub",
+ type: "POST",
+ url: "/api/ExecCommunityRepo",
+ icon: ,
+ data: {
+ Action: "UploadTemplate",
+ GUID: "GUID",
+ },
+ fields: [
+ {
+ label: "Repository",
+ name: "FullName",
+ type: "select",
+ api: {
+ url: "/api/ListCommunityRepos",
+ data: {
+ WriteAccess: true,
+ },
+ queryKey: "CommunityRepos-Write",
+ dataKey: "Results",
+ valueField: "FullName",
+ labelField: "FullName",
+ },
+ multiple: false,
+ creatable: false,
+ required: true,
+ validators: {
+ required: { value: true, message: "This field is required" },
+ },
+ },
+ {
+ label: "Commit Message",
+ placeholder: "Enter a commit message for adding this file to GitHub",
+ name: "Message",
+ type: "textField",
+ multiline: true,
+ required: true,
+ rows: 4,
+ },
+ ],
+ confirmText: "Are you sure you want to save this template to the selected repository?",
+ condition: () => integrations.isSuccess && integrations?.data?.GitHub?.Enabled,
+ },
+ {
+ label: "Delete Template",
+ type: "POST",
+ url: "/api/RemoveCATemplate",
+ icon: ,
+ data: { ID: "GUID" },
+ confirmText: "Do you want to delete the template?",
+ multiPost: false,
+ },
+ ];
+
+ const offCanvas = {
+ children: (row) => ,
+ size: "xl",
+ };
+ return (
+ <>
+
+ }
+ >
+ Create Template
+
+
+
+
+ }
+ />
+ setDeployDrawerOpen(false)}
+ templateId={selectedTemplateId}
+ />
+ >
+ );
+};
+
+Page.getLayout = (page) => {page};
+
+export default Page;
diff --git a/src/pages/tenant/gdap-management/index.js b/src/pages/tenant/gdap-management/index.js
deleted file mode 100644
index f1bde321dd71..000000000000
--- a/src/pages/tenant/gdap-management/index.js
+++ /dev/null
@@ -1,227 +0,0 @@
-import { TabbedLayout } from '../../../layouts/TabbedLayout'
-import { Layout as DashboardLayout } from '../../../layouts/index.js'
-import tabOptions from './tabOptions'
-import { Container } from '@mui/system'
-import { Grid } from '@mui/system'
-import { CippInfoBar } from '../../../components/CippCards/CippInfoBar'
-import { ApiPostCall, ApiGetCallWithPagination } from '../../../api/ApiCall'
-import {
- Add,
- AdminPanelSettings,
- HourglassBottom,
- Layers,
- SupervisorAccount,
-} from '@mui/icons-material'
-import CippPermissionCheck from '../../../components/CippSettings/CippPermissionCheck'
-import { Button } from '@mui/material'
-import { useEffect, useState } from 'react'
-import CippButtonCard from '../../../components/CippCards/CippButtonCard'
-import { WizardSteps } from '../../../components/CippWizard/wizard-steps'
-import Link from 'next/link'
-import { CippHead } from '../../../components/CippComponents/CippHead'
-import { usePermissions } from '../../../hooks/use-permissions'
-
-const Page = () => {
- const [createDefaults, setCreateDefaults] = useState(false)
- const [activeStep, setActiveStep] = useState(0)
- const { checkPermissions } = usePermissions()
- const canViewGdapChecks = checkPermissions(['CIPP.AppSettings.Read'])
-
- const relationships = ApiGetCallWithPagination({
- url: '/api/ListGDAPRelationships',
- queryKey: 'ListGDAPRelationships',
- waiting: true,
- })
-
- const mappedRoles = ApiGetCallWithPagination({
- url: '/api/ListGDAPRoles',
- queryKey: 'ListGDAPRoles',
- waiting: true,
- })
-
- const roleTemplates = ApiGetCallWithPagination({
- url: '/api/ExecGDAPRoleTemplate',
- queryKey: 'ListGDAPRoleTemplates',
- waiting: true,
- })
-
- const pendingInvites = ApiGetCallWithPagination({
- url: '/api/ListGDAPInvite',
- queryKey: 'ListGDAPInvite',
- waiting: true,
- })
-
- const createCippDefaults = ApiPostCall({
- urlFromData: true,
- relatedQueryKeys: ['ListGDAPRoleTemplates', 'ListGDAPRoles'],
- })
-
- useEffect(() => {
- if (roleTemplates.isSuccess) {
- var promptCreateDefaults = true
- // check templates for CIPP Defaults
- const firstPageResults = roleTemplates?.data?.pages?.[0]?.Results
- if (
- firstPageResults &&
- Array.isArray(firstPageResults) &&
- firstPageResults.length > 0 &&
- firstPageResults.find((t) => t?.TemplateId === 'CIPP Defaults')
- ) {
- promptCreateDefaults = false
- }
- setCreateDefaults(promptCreateDefaults)
- }
- }, [roleTemplates])
-
- useEffect(() => {
- if (mappedRoles.isSuccess && roleTemplates.isSuccess && pendingInvites.isSuccess) {
- const mappedRolesFirstPage = mappedRoles?.data?.pages?.[0]
- if (
- mappedRolesFirstPage &&
- Array.isArray(mappedRolesFirstPage) &&
- mappedRolesFirstPage.length > 0
- ) {
- setActiveStep(1)
-
- const roleTemplatesFirstPage = roleTemplates?.data?.pages?.[0]?.Results
- if (
- roleTemplatesFirstPage &&
- Array.isArray(roleTemplatesFirstPage) &&
- roleTemplatesFirstPage.length > 0
- ) {
- setActiveStep(2)
-
- const pendingInvitesFirstPage = pendingInvites?.data?.pages?.[0]
- if (
- pendingInvitesFirstPage &&
- Array.isArray(pendingInvitesFirstPage) &&
- pendingInvitesFirstPage.length > 0
- ) {
- setActiveStep(4)
- }
- }
- }
- }
- }, [
- relationships.isSuccess,
- mappedRoles.isSuccess,
- roleTemplates.isSuccess,
- roleTemplates.isFetching,
- pendingInvites.isSuccess,
- ])
-
- return (
-
-
-
-
- ,
- data:
- relationships.data?.pages
- ?.map((page) => page?.Results?.length || 0)
- .reduce((a, b) => (a || 0) + (b || 0), 0) ?? 0,
- name: 'GDAP Relationships',
- color: 'secondary',
- },
- {
- icon: ,
- data:
- mappedRoles.data?.pages
- ?.map((page) => page?.length || 0)
- .reduce((a, b) => (a || 0) + (b || 0), 0) ?? 0,
- name: 'Mapped Admin Roles',
- color: 'green',
- },
- {
- icon: ,
- data:
- roleTemplates.data?.pages
- ?.map((page) => page?.Results?.length || 0)
- .reduce((a, b) => (a || 0) + (b || 0), 0) ?? 0,
- name: 'Role Templates',
- },
- {
- icon: ,
- data:
- pendingInvites.data?.pages
- ?.map((page) => page?.length || 0)
- .reduce((a, b) => (a || 0) + (b || 0), 0) ?? 0,
- name: 'Pending Invites',
- },
- ]}
- />
-
-
- }
- variant="contained"
- >
- Add a Tenant
-
-
- {canViewGdapChecks && (
- <>
-
-
-
-
-
-
-
-
- >
- )}
-
-
- )
-}
-
-Page.getLayout = (page) => (
-
- {page}
-
-)
-
-export default Page
diff --git a/src/pages/tenant/gdap-management/index.jsx b/src/pages/tenant/gdap-management/index.jsx
new file mode 100644
index 000000000000..54376b8c3cac
--- /dev/null
+++ b/src/pages/tenant/gdap-management/index.jsx
@@ -0,0 +1,203 @@
+import { TabbedLayout } from '../../../layouts/TabbedLayout'
+import { CippIcons } from '../../../utils/icon-registry'
+import { Layout as DashboardLayout } from '../../../layouts/index'
+import tabOptions from './tabOptions'
+import { Container } from '@mui/system'
+import { Grid } from '@mui/system'
+import { CippInfoBar } from '../../../components/CippCards/CippInfoBar'
+import { ApiPostCall, ApiGetCallWithPagination } from '../../../api/ApiCall'
+import CippPermissionCheck from '../../../components/CippSettings/CippPermissionCheck'
+import { Button } from '@mui/material'
+import { useEffect, useState } from 'react'
+import CippButtonCard from '../../../components/CippCards/CippButtonCard'
+import { WizardSteps } from '../../../components/CippWizard/wizard-steps'
+import Link from 'next/link'
+import { CippHead } from '../../../components/CippComponents/CippHead'
+import { usePermissions } from '../../../hooks/use-permissions'
+
+const Page = () => {
+ const [createDefaults, setCreateDefaults] = useState(false)
+ const [activeStep, setActiveStep] = useState(0)
+ const { checkPermissions } = usePermissions()
+ const canViewGdapChecks = checkPermissions(['CIPP.AppSettings.Read'])
+
+ const relationships = ApiGetCallWithPagination({
+ url: '/api/ListGDAPRelationships',
+ queryKey: 'ListGDAPRelationships',
+ waiting: true,
+ })
+
+ const mappedRoles = ApiGetCallWithPagination({
+ url: '/api/ListGDAPRoles',
+ queryKey: 'ListGDAPRoles',
+ waiting: true,
+ })
+
+ const roleTemplates = ApiGetCallWithPagination({
+ url: '/api/ExecGDAPRoleTemplate',
+ queryKey: 'ListGDAPRoleTemplates',
+ waiting: true,
+ })
+
+ const pendingInvites = ApiGetCallWithPagination({
+ url: '/api/ListGDAPInvite',
+ queryKey: 'ListGDAPInvite',
+ waiting: true,
+ })
+
+ const createCippDefaults = ApiPostCall({
+ urlFromData: true,
+ relatedQueryKeys: ['ListGDAPRoleTemplates', 'ListGDAPRoles'],
+ })
+
+ useEffect(() => {
+ if (roleTemplates.isSuccess) {
+ var promptCreateDefaults = true
+ // check templates for CIPP Defaults
+ const firstPageResults = roleTemplates?.data?.pages?.[0]?.Results
+ if (
+ firstPageResults &&
+ Array.isArray(firstPageResults) &&
+ firstPageResults.length > 0 &&
+ firstPageResults.find((t) => t?.TemplateId === 'CIPP Defaults')
+ ) {
+ promptCreateDefaults = false
+ }
+ setCreateDefaults(promptCreateDefaults)
+ }
+ }, [roleTemplates])
+
+ useEffect(() => {
+ if (roleTemplates.isSuccess && pendingInvites.isSuccess) {
+ const roleTemplatesFirstPage = roleTemplates?.data?.pages?.[0]?.Results
+ const hasTemplates =
+ Array.isArray(roleTemplatesFirstPage) && roleTemplatesFirstPage.length > 0
+ if (!hasTemplates) {
+ setActiveStep(0)
+ return
+ }
+
+ const pendingInvitesFirstPage = pendingInvites?.data?.pages?.[0]
+ const hasInvites =
+ Array.isArray(pendingInvitesFirstPage) && pendingInvitesFirstPage.length > 0
+ setActiveStep(hasInvites ? 2 : 1)
+ }
+ }, [
+ relationships.isSuccess,
+ mappedRoles.isSuccess,
+ roleTemplates.isSuccess,
+ roleTemplates.isFetching,
+ pendingInvites.isSuccess,
+ ])
+
+ return (
+
+
+
+
+ ,
+ data:
+ relationships.data?.pages
+ ?.map((page) => page?.Results?.length || 0)
+ .reduce((a, b) => (a || 0) + (b || 0), 0) ?? 0,
+ name: 'GDAP Relationships',
+ color: 'secondary',
+ },
+ {
+ icon: ,
+ data:
+ mappedRoles.data?.pages
+ ?.map((page) => page?.length || 0)
+ .reduce((a, b) => (a || 0) + (b || 0), 0) ?? 0,
+ name: 'Mapped Admin Roles',
+ color: 'green',
+ },
+ {
+ icon: ,
+ data:
+ roleTemplates.data?.pages
+ ?.map((page) => page?.Results?.length || 0)
+ .reduce((a, b) => (a || 0) + (b || 0), 0) ?? 0,
+ name: 'Role Templates',
+ },
+ {
+ icon: ,
+ data:
+ pendingInvites.data?.pages
+ ?.map((page) => page?.length || 0)
+ .reduce((a, b) => (a || 0) + (b || 0), 0) ?? 0,
+ name: 'Pending Invites',
+ },
+ ]}
+ />
+
+
+ }
+ variant="contained"
+ >
+ Add a Tenant
+
+
+ {canViewGdapChecks && (
+ <>
+
+
+
+
+
+
+
+
+ >
+ )}
+
+
+ )
+}
+
+Page.getLayout = (page) => (
+
+ {page}
+
+)
+
+export default Page
diff --git a/src/pages/tenant/gdap-management/invites/add.js b/src/pages/tenant/gdap-management/invites/add.js
deleted file mode 100644
index 660644782135..000000000000
--- a/src/pages/tenant/gdap-management/invites/add.js
+++ /dev/null
@@ -1,266 +0,0 @@
-import { Layout as DashboardLayout } from "../../../../layouts/index.js";
-import { useForm, useWatch } from "react-hook-form";
-import CippFormComponent from "../../../../components/CippComponents/CippFormComponent";
-import { Grid } from "@mui/system";
-import CippPageCard from "../../../../components/CippCards/CippPageCard";
-import { ApiGetCall, ApiPostCall } from "../../../../api/ApiCall";
-import { CippDataTable } from "../../../../components/CippTable/CippDataTable";
-import { CippApiResults } from "../../../../components/CippComponents/CippApiResults";
-import { CippExpandableAlert } from "../../../../components/CippComponents/CippExpandableAlert";
-import {
- Accordion,
- AccordionDetails,
- AccordionSummary,
- Alert,
- Button,
- CardActions,
- CardContent,
- CircularProgress,
- List,
- Link,
- ListItem,
- SvgIcon,
- Typography,
-} from "@mui/material";
-import { PlusIcon } from "@heroicons/react/24/outline";
-import { useEffect, useState } from "react";
-import { CippPropertyList } from "../../../../components/CippComponents/CippPropertyList";
-import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
-import NextLink from "next/link";
-
-const Page = () => {
- const [inviteData, setInviteData] = useState([]);
- const [createDefaults, setCreateDefaults] = useState(false);
-
- const formControl = useForm({
- mode: "onChange",
- defaultValues: {
- inviteCount: 1,
- },
- });
-
- const createCippDefaults = ApiPostCall({
- urlFromData: true,
- relatedQueryKeys: ["ListGDAPRoleTemplatesAutocomplete", "ListGDAPRoleTemplates"],
- });
-
- const templateList = ApiGetCall({
- url: "/api/ExecGDAPRoleTemplate",
- queryKey: "ListGDAPRoleTemplates-list",
- });
- const selectedTemplate = useWatch({ control: formControl.control, name: "roleMappings" });
-
- useEffect(() => {
- if (templateList?.data?.Results?.length === 0) {
- setCreateDefaults(true);
- } else {
- setCreateDefaults(false);
- }
- }, [templateList.isSuccess]);
-
- const addInvites = ApiPostCall({
- urlFromData: true,
- relatedQueryKeys: ["GDAPInvite"],
- });
-
- const handleSubmit = (values) => {
- formControl.trigger();
- if (!formControl.formState.isValid) return;
- const eachInvite = Array.from({ length: values.inviteCount }, (_, i) => ({
- roleMappings: values.roleMappings.value,
- Reference: values.Reference,
- }));
-
- addInvites.mutate({
- url: "/api/ExecGDAPInvite",
- bulkRequest: true,
- data: eachInvite,
- });
- };
-
- useEffect(() => {
- if (addInvites?.data?.length > 0) {
- setInviteData((prevData) => {
- const newData = addInvites.data.map((invite) => ({
- ...invite.Invite,
- Message: invite.Message,
- }));
- const mergedData = [...prevData, ...newData];
- const deduplicatedData = mergedData.filter(
- (item, index, self) => index === self.findIndex((t) => t.InviteUrl === item.InviteUrl)
- );
- return deduplicatedData;
- });
- }
- }, [addInvites?.data?.length]);
-
- return (
- <>
-
-
-
-
-
-
- Use this form to generate invites for the selected GDAP Role Template. After
- generating the invite, you will receive two URLs:
-
-
-
- The Invite link is to send to a client or accept as a Global Administrator on
- the customer tenant.
-
-
- The Onboarding link is for a CIPP Administrator to complete the onboarding
- process.
-
-
-
- The onboarding process will also run on a nightly schedule. For automated
- onboardings, please check out{" "}
-
- Automated Onboarding
- {" "}
- in Application Settings.
-
-
-
- {createDefaults && (
- <>
-
-
- The CIPP Defaults template is missing from the GDAP Role Templates. Create it
- now?
-
- createCippDefaults.mutate({
- url: "/api/ExecAddGDAPRole",
- data: { TemplateId: "CIPP Defaults" },
- })
- }
- sx={{ ml: 2 }}
- startIcon={
-
-
-
- }
- >
- Create CIPP Defaults
-
-
-
-
-
-
- >
- )}
-
- option.TemplateId,
- valueField: (option) => option.RoleMappings,
- }}
- multiple={false}
- creatable={false}
- required={true}
- validators={{
- validate: (value) => {
- if (!value) {
- return "Please select a GDAP Role Template";
- }
- return true;
- },
- }}
- />
-
-
-
-
-
-
-
- {selectedTemplate?.value && (
-
-
- }>
- Selected Role Mappings
-
-
- {
- return {
- label: `${role.RoleName}`,
- value: `Mapped to '${role.GroupName}'`,
- };
- })}
- />
-
-
-
- )}
- {addInvites.isPending && (
-
-
- Generating invites...
-
-
- )}
- {inviteData?.length > 0 && (
- <>
-
-
-
- >
- )}
-
-
-
-
-
-
- }
- >
- Add Invites
-
-
-
- >
- );
-};
-
-Page.getLayout = (page) => {page};
-
-export default Page;
diff --git a/src/pages/tenant/gdap-management/invites/add.jsx b/src/pages/tenant/gdap-management/invites/add.jsx
new file mode 100644
index 000000000000..57e44ceedc9a
--- /dev/null
+++ b/src/pages/tenant/gdap-management/invites/add.jsx
@@ -0,0 +1,282 @@
+import { Layout as DashboardLayout } from "../../../../layouts/index";
+import { CippIcons } from "../../../../utils/icon-registry";
+import { useForm, useWatch } from "react-hook-form";
+import CippFormComponent from "../../../../components/CippComponents/CippFormComponent";
+import { Grid } from "@mui/system";
+import CippPageCard from "../../../../components/CippCards/CippPageCard";
+import { ApiGetCall, ApiPostCall } from "../../../../api/ApiCall";
+import { CippDataTable } from "../../../../components/CippTable/CippDataTable";
+import { CippApiResults } from "../../../../components/CippComponents/CippApiResults";
+import { CippExpandableAlert } from "../../../../components/CippComponents/CippExpandableAlert";
+import {
+ Accordion,
+ AccordionDetails,
+ AccordionSummary,
+ Alert,
+ Button,
+ CardActions,
+ CardContent,
+ CircularProgress,
+ List,
+ Link,
+ ListItem,
+ SvgIcon,
+ Typography,
+} from "@mui/material";
+import { useEffect, useState } from "react";
+import { CippPropertyList } from "../../../../components/CippComponents/CippPropertyList";
+import NextLink from "next/link";
+import { useRouter } from "next/router";
+
+const Page = () => {
+ const router = useRouter();
+ const { templateId } = router.query;
+ const [inviteData, setInviteData] = useState([]);
+ const [createDefaults, setCreateDefaults] = useState(false);
+
+ const formControl = useForm({
+ mode: "onChange",
+ defaultValues: {
+ inviteCount: 1,
+ },
+ });
+
+ const createCippDefaults = ApiPostCall({
+ urlFromData: true,
+ relatedQueryKeys: ["ListGDAPRoleTemplatesAutocomplete", "ListGDAPRoleTemplates"],
+ });
+
+ const templateList = ApiGetCall({
+ url: "/api/ExecGDAPRoleTemplate",
+ queryKey: "ListGDAPRoleTemplates-list",
+ });
+ const selectedTemplate = useWatch({ control: formControl.control, name: "roleMappings" });
+
+ useEffect(() => {
+ if (templateList?.data?.Results?.length === 0) {
+ setCreateDefaults(true);
+ } else {
+ setCreateDefaults(false);
+ }
+ }, [templateList.isSuccess]);
+
+ // Arriving from a role template's "Create Invite" action: preselect that template.
+ useEffect(() => {
+ if (!templateId || !templateList.isSuccess) return;
+ const template = (templateList?.data?.Results ?? []).find(
+ (t) => t.TemplateId === templateId
+ );
+ if (template) {
+ formControl.setValue("roleMappings", {
+ label: template.TemplateId,
+ value: template.RoleMappings,
+ });
+ }
+ }, [templateId, templateList.isSuccess, templateList.data]);
+
+ const addInvites = ApiPostCall({
+ urlFromData: true,
+ relatedQueryKeys: ["GDAPInvite"],
+ });
+
+ const handleSubmit = (values) => {
+ formControl.trigger();
+ if (!formControl.formState.isValid) return;
+ const eachInvite = Array.from({ length: values.inviteCount }, (_, i) => ({
+ roleMappings: values.roleMappings.value,
+ Reference: values.Reference,
+ }));
+
+ addInvites.mutate({
+ url: "/api/ExecGDAPInvite",
+ bulkRequest: true,
+ data: eachInvite,
+ });
+ };
+
+ useEffect(() => {
+ if (addInvites?.data?.length > 0) {
+ setInviteData((prevData) => {
+ const newData = addInvites.data.map((invite) => ({
+ ...invite.Invite,
+ Message: invite.Message,
+ }));
+ const mergedData = [...prevData, ...newData];
+ const deduplicatedData = mergedData.filter(
+ (item, index, self) => index === self.findIndex((t) => t.InviteUrl === item.InviteUrl)
+ );
+ return deduplicatedData;
+ });
+ }
+ }, [addInvites?.data?.length]);
+
+ return (
+ <>
+
+
+
+
+
+
+ Use this form to generate invites for the selected GDAP Role Template. After
+ generating the invite, you will receive two URLs:
+
+
+
+ The Invite link is to send to a client or accept as a Global Administrator on
+ the customer tenant.
+
+
+ The Onboarding link is for a CIPP Administrator to complete the onboarding
+ process.
+
+
+
+ The onboarding process will also run on a nightly schedule. For automated
+ onboardings, please check out{" "}
+
+ Automated Onboarding
+ {" "}
+ in Application Settings.
+
+
+
+ {createDefaults && (
+ <>
+
+
+ The CIPP Defaults template is missing from the GDAP Role Templates. Create it
+ now?
+
+ createCippDefaults.mutate({
+ url: "/api/ExecAddGDAPRole",
+ data: { TemplateId: "CIPP Defaults" },
+ })
+ }
+ sx={{ ml: 2 }}
+ startIcon={
+
+
+
+ }
+ >
+ Create CIPP Defaults
+
+
+
+
+
+
+ >
+ )}
+
+ option.TemplateId,
+ valueField: (option) => option.RoleMappings,
+ }}
+ multiple={false}
+ creatable={false}
+ required={true}
+ validators={{
+ validate: (value) => {
+ if (!value) {
+ return "Please select a GDAP Role Template";
+ }
+ return true;
+ },
+ }}
+ />
+
+
+
+
+
+
+
+ {selectedTemplate?.value && (
+
+
+ }>
+ Selected Role Mappings
+
+
+ {
+ return {
+ label: `${role.RoleName}`,
+ value: `Mapped to '${role.GroupName}'`,
+ };
+ })}
+ />
+
+
+
+ )}
+ {addInvites.isPending && (
+
+
+ Generating invites...
+
+
+ )}
+ {inviteData?.length > 0 && (
+ <>
+
+
+
+ >
+ )}
+
+
+
+
+
+
+ }
+ >
+ Add Invites
+
+
+
+ >
+ );
+};
+
+Page.getLayout = (page) => {page};
+
+export default Page;
diff --git a/src/pages/tenant/gdap-management/invites/index.js b/src/pages/tenant/gdap-management/invites/index.js
deleted file mode 100644
index 39729ae02fad..000000000000
--- a/src/pages/tenant/gdap-management/invites/index.js
+++ /dev/null
@@ -1,76 +0,0 @@
-import { TabbedLayout } from "../../../../layouts/TabbedLayout";
-import { Layout as DashboardLayout } from "../../../../layouts/index.js";
-import tabOptions from "../tabOptions";
-import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx";
-import { Button } from "@mui/material";
-import { Add } from "@mui/icons-material";
-import Link from "next/link";
-import { TrashIcon, PencilIcon } from "@heroicons/react/24/outline";
-
-const pageTitle = "GDAP Invites";
-const simpleColumns = ["Timestamp", "RowKey", "Reference", "Technician", "InviteUrl", "OnboardingUrl", "RoleMappings"];
-const apiUrl = "/api/ListGDAPInvite";
-
-const actions = [
- {
- label: "Update Internal Reference",
- url: "/api/ExecGDAPInvite",
- type: "POST",
- icon: ,
- confirmText: "Are you sure you want to update the internal reference for this invite?",
- data: {
- Action: "Update",
- InviteId: "RowKey",
- },
- fields: [
- {
- label: "Internal Reference",
- name: "Reference",
- type: "textField",
- required: false,
- helperText: "Enter an internal reference/note for this GDAP invite (e.g., client name, ticket number).",
- },
- ],
- relatedQueryKeys: ["ListGDAPInvite"],
- },
- {
- label: "Delete Invite",
- url: "/api/ExecGDAPInvite",
- type: "POST",
- icon: ,
- confirmText:
- "Are you sure you want to delete this invite? This only removes the entry from the database, GDAP relationships cannot be terminated once they are in approval pending status.",
- data: {
- Action: "Delete",
- InviteId: "RowKey",
- },
- relatedQueryKeys: ["ListGDAPInvite"],
- },
-];
-
-const Page = () => {
- return (
- }>
- New Invite
-
- }
- title={pageTitle}
- apiUrl={apiUrl}
- simpleColumns={simpleColumns}
- actions={actions}
- tenantInTitle={false}
- queryKey="ListGDAPInvite"
- maxHeightOffset="460px"
- />
- );
-};
-
-Page.getLayout = (page) => (
-
- {page}
-
-);
-
-export default Page;
diff --git a/src/pages/tenant/gdap-management/invites/index.jsx b/src/pages/tenant/gdap-management/invites/index.jsx
new file mode 100644
index 000000000000..652355550916
--- /dev/null
+++ b/src/pages/tenant/gdap-management/invites/index.jsx
@@ -0,0 +1,75 @@
+import { TabbedLayout } from "../../../../layouts/TabbedLayout";
+import { CippIcons } from "../../../../utils/icon-registry"
+import { Layout as DashboardLayout } from "../../../../layouts/index";
+import tabOptions from "../tabOptions";
+import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx";
+import { Button } from "@mui/material";
+import Link from "next/link";
+
+const pageTitle = "GDAP Invites";
+const simpleColumns = ["Timestamp", "RowKey", "Reference", "Technician", "InviteUrl", "OnboardingUrl", "RoleMappings"];
+const apiUrl = "/api/ListGDAPInvite";
+
+const actions = [
+ {
+ label: "Update Internal Reference",
+ url: "/api/ExecGDAPInvite",
+ type: "POST",
+ icon: ,
+ confirmText: "Are you sure you want to update the internal reference for this invite?",
+ data: {
+ Action: "Update",
+ InviteId: "RowKey",
+ },
+ fields: [
+ {
+ label: "Internal Reference",
+ name: "Reference",
+ type: "textField",
+ required: false,
+ helperText: "Enter an internal reference/note for this GDAP invite (e.g., client name, ticket number).",
+ },
+ ],
+ relatedQueryKeys: ["ListGDAPInvite"],
+ },
+ {
+ label: "Delete Invite",
+ url: "/api/ExecGDAPInvite",
+ type: "POST",
+ icon: ,
+ confirmText:
+ "Are you sure you want to delete this invite? This only removes the entry from the database, GDAP relationships cannot be terminated once they are in approval pending status.",
+ data: {
+ Action: "Delete",
+ InviteId: "RowKey",
+ },
+ relatedQueryKeys: ["ListGDAPInvite"],
+ },
+];
+
+const Page = () => {
+ return (
+ }>
+ New Invite
+
+ }
+ title={pageTitle}
+ apiUrl={apiUrl}
+ simpleColumns={simpleColumns}
+ actions={actions}
+ tenantInTitle={false}
+ queryKey="ListGDAPInvite"
+ maxHeightOffset="460px"
+ />
+ );
+};
+
+Page.getLayout = (page) => (
+
+ {page}
+
+);
+
+export default Page;
diff --git a/src/pages/tenant/gdap-management/offboarding.js b/src/pages/tenant/gdap-management/offboarding.js
deleted file mode 100644
index 5cfc5be94fbf..000000000000
--- a/src/pages/tenant/gdap-management/offboarding.js
+++ /dev/null
@@ -1,294 +0,0 @@
-import CippFormPage from "../../../components/CippFormPages/CippFormPage";
-import { TabbedLayout } from "../../../layouts/TabbedLayout";
-import { Layout as DashboardLayout } from "../../../layouts/index.js";
-import tabOptions from "./tabOptions";
-import { useForm, useWatch } from "react-hook-form";
-import { CippFormComponent } from "../../../components/CippComponents/CippFormComponent";
-import vendorTenantList from "../../../data/vendorTenantList";
-import { Box, Grid, Stack } from "@mui/system";
-import { Alert, Divider, Typography } from "@mui/material";
-import { ApiGetCall, ApiGetCallWithPagination } from "../../../api/ApiCall";
-import { CippInfoBar } from "../../../components/CippCards/CippInfoBar";
-import { ShieldCheckIcon } from "@heroicons/react/24/outline";
-import { Apps, Description, Widgets } from "@mui/icons-material";
-
-const Page = () => {
- const formControl = useForm({
- mode: "onChange",
- });
-
- const vendorFilter = vendorTenantList
- .map((vendor) => {
- return vendor.vendorTenantId;
- })
- .join(",");
- const tenantId = useWatch({
- control: formControl.control,
- name: "tenantFilter",
- });
-
- const gdapRelationships = ApiGetCall({
- url: "/api/ListGDAPRelationships",
- queryKey: "ListGDAPRelationship",
- });
-
- const cspContracts = ApiGetCall({
- url: "/api/ListGDAPContracts",
- queryKey: "ListContracts",
- });
-
- const mspApps = ApiGetCall({
- url: "/api/ListGDAPServicePrincipals",
- data: {
- tenantFilter: tenantId?.value,
- ownerType: "partner",
- },
- queryKey: "ListMSPApps-" + tenantId?.value,
- waiting: Boolean(tenantId?.value),
- });
-
- const vendorApps = ApiGetCallWithPagination({
- url: "/api/ListGDAPServicePrincipals",
- data: {
- tenantFilter: tenantId?.value,
- ownerType: "vendor",
- vendorTenantIds: vendorFilter,
- },
- queryKey: "ListVendorApps-" + tenantId?.value,
- waiting: Boolean(tenantId?.value),
- });
-
- return (
- <>
-
-
-
-
- This page is used to offboard a tenant. Please select the tenant and the actions to be
- performed. Please note that once an offboarding has been executed, it cannot be
- undone.
-
-
-
- {
- return `${tenant.displayName} (${tenant.defaultDomainName})`;
- },
- valueField: "customerId",
- }}
- required={true}
- multiple={false}
- creatable={false}
- validators={{
- validate: (value) => {
- if (!value) {
- return "Tenant is required";
- }
- return true;
- },
- }}
- />
-
- {tenantId && (
- <>
-
-
-
-
- relationship?.customer?.tenantId === tenantId.value
- )?.length ?? 0,
- icon: ,
- offcanvas: {
- title: "GDAP Relationships",
- propertyItems: gdapRelationships.data?.Results?.filter(
- (relationship) => relationship?.customer?.tenantId === tenantId.value
- )?.map((relationship) => ({
- label: `Relationship: ${relationship?.displayName}`,
- value: `Id: ${relationship?.id}`,
- })),
- },
- },
- {
- name: "CSP Contract",
- data:
- cspContracts.data?.Results?.filter(
- (contract) => contract?.customerId === tenantId.value
- )?.length === 1
- ? "Yes"
- : "No",
- icon: ,
- },
- {
- name: "MSP Applications",
- data: mspApps.data?.Results?.length ?? 0,
- icon: ,
- offcanvas: {
- title: "MSP Applications",
- propertyItems: mspApps.data?.Results?.map((app) => ({
- label: app?.displayName,
- value: app?.appId,
- })),
- },
- },
- {
- name: "Vendor Applications",
- data:
- vendorApps.data?.pages?.reduce(
- (sum, page) => sum + (page?.Results?.length ?? 0),
- 0
- ) ?? 0,
- icon: ,
- offcanvas: {
- title: "Vendor Applications",
- propertyItems: vendorApps.data?.pages
- ?.reduce((sum, page) => sum.concat(page?.Results ?? []), [])
- .map((app) => ({
- label: app?.displayName,
- value: app?.appId,
- })),
- },
- },
- ]}
- />
-
-
- Offboarding actions to perform
-
- The tenant will not be fully offboarded unless all the relationships/contracts are
- terminated.
-
-
-
-
- {
- const vendor = vendorTenantList.find(
- (v) => v?.vendorTenantId === app?.appOwnerOrganizationId
- );
- return `${vendor?.vendorName} - ${app?.displayName}`;
- },
- valueField: "appId",
- }}
- disabled={vendorApps?.data?.pages?.[0]?.Results?.length > 0 ? false : true}
- />
- 0 ? false : true}
- />
- 0 ? false : true}
- />
-
-
-
-
-
-
- These actions will terminate all delegated access to the customer tenant!
-
-
-
- 0 ? false : true}
- />
- relationship?.customer?.tenantId === tenantId.value
- )
- ? false
- : true
- }
- />
- contact.customerId === tenantId.value
- )
- ? false
- : true
- }
- />
-
-
-
-
- >
- )}
-
-
- >
- );
-};
-
-Page.getLayout = (page) => (
-
- {page}
-
-);
-
-export default Page;
diff --git a/src/pages/tenant/gdap-management/offboarding.jsx b/src/pages/tenant/gdap-management/offboarding.jsx
new file mode 100644
index 000000000000..c60fc0b03e97
--- /dev/null
+++ b/src/pages/tenant/gdap-management/offboarding.jsx
@@ -0,0 +1,295 @@
+import CippFormPage from "../../../components/CippFormPages/CippFormPage";
+import { CippIcons } from "../../../utils/icon-registry"
+import { TabbedLayout } from "../../../layouts/TabbedLayout";
+import { Layout as DashboardLayout } from "../../../layouts/index";
+import tabOptions from "./tabOptions";
+import { useForm, useWatch } from "react-hook-form";
+import { CippFormComponent } from "../../../components/CippComponents/CippFormComponent";
+import vendorTenantList from "../../../data/vendorTenantList";
+import { Box, Grid, Stack } from "@mui/system";
+import { Alert, Divider, Typography } from "@mui/material";
+import { ApiGetCall, ApiGetCallWithPagination } from "../../../api/ApiCall";
+import { CippInfoBar } from "../../../components/CippCards/CippInfoBar";
+
+const Page = () => {
+ const formControl = useForm({
+ mode: "onChange",
+ });
+
+ const vendorFilter = vendorTenantList
+ .map((vendor) => {
+ return vendor.vendorTenantId;
+ })
+ .join(",");
+ const tenantId = useWatch({
+ control: formControl.control,
+ name: "tenantFilter",
+ });
+
+ const gdapRelationships = ApiGetCall({
+ url: "/api/ListGDAPRelationships",
+ queryKey: "ListGDAPRelationship",
+ });
+
+ const cspContracts = ApiGetCall({
+ url: "/api/ListGDAPContracts",
+ queryKey: "ListContracts",
+ });
+
+ const mspApps = ApiGetCall({
+ url: "/api/ListGDAPServicePrincipals",
+ data: {
+ tenantFilter: tenantId?.value,
+ ownerType: "partner",
+ },
+ queryKey: "ListMSPApps-" + tenantId?.value,
+ waiting: Boolean(tenantId?.value),
+ });
+
+ const vendorApps = ApiGetCallWithPagination({
+ url: "/api/ListGDAPServicePrincipals",
+ data: {
+ tenantFilter: tenantId?.value,
+ ownerType: "vendor",
+ vendorTenantIds: vendorFilter,
+ },
+ queryKey: "ListVendorApps-" + tenantId?.value,
+ waiting: Boolean(tenantId?.value),
+ });
+
+ return (
+ <>
+
+
+
+
+ This page is used to offboard a tenant. Please select the tenant and the actions to be
+ performed. Please note that once an offboarding has been executed, it cannot be
+ undone.
+
+
+
+ {
+ return `${tenant.displayName} (${tenant.defaultDomainName})`;
+ },
+ valueField: "customerId",
+ }}
+ required={true}
+ multiple={false}
+ creatable={false}
+ validators={{
+ validate: (value) => {
+ if (!value) {
+ return "Tenant is required";
+ }
+ return true;
+ },
+ }}
+ />
+
+ {tenantId && (
+ <>
+
+
+
+
+ relationship?.customer?.tenantId === tenantId.value
+ )?.length ?? 0,
+ icon: ,
+ offcanvas: {
+ title: "GDAP Relationships",
+ propertyItems: gdapRelationships.data?.Results?.filter(
+ (relationship) => relationship?.customer?.tenantId === tenantId.value
+ )?.map((relationship) => ({
+ label: `Relationship: ${relationship?.displayName}`,
+ value: `Id: ${relationship?.id}`,
+ })),
+ },
+ },
+ {
+ name: "CSP Contract",
+ data:
+ cspContracts.data?.Results?.filter(
+ (contract) => contract?.customerId === tenantId.value
+ )?.length === 1
+ ? "Yes"
+ : "No",
+ icon: ,
+ },
+ {
+ name: "MSP Applications",
+ data: mspApps.data?.Results?.length ?? 0,
+ icon: ,
+ offcanvas: {
+ title: "MSP Applications",
+ propertyItems: mspApps.data?.Results?.map((app) => ({
+ label: app?.displayName,
+ value: app?.appId,
+ })),
+ },
+ },
+ {
+ name: "Vendor Applications",
+ data:
+ vendorApps.data?.pages?.reduce(
+ (sum, page) => sum + (page?.Results?.length ?? 0),
+ 0
+ ) ?? 0,
+ icon: ,
+ offcanvas: {
+ title: "Vendor Applications",
+ propertyItems: vendorApps.data?.pages
+ ?.reduce((sum, page) => sum.concat(page?.Results ?? []), [])
+ .map((app) => ({
+ label: app?.displayName,
+ value: app?.appId,
+ })),
+ },
+ },
+ ]}
+ />
+
+
+ Offboarding actions to perform
+
+ The tenant will not be fully offboarded unless all the relationships/contracts are
+ terminated.
+
+
+
+
+ {
+ const vendor = vendorTenantList.find(
+ (v) => v?.vendorTenantId === app?.appOwnerOrganizationId
+ );
+ return `${vendor?.vendorName} - ${app?.displayName}`;
+ },
+ valueField: "appId",
+ }}
+ disabled={vendorApps?.data?.pages?.[0]?.Results?.length > 0 ? false : true}
+ />
+ 0 ? false : true}
+ />
+ 0 ? false : true}
+ />
+
+
+
+
+
+
+ These actions will terminate all delegated access to the customer tenant!
+
+
+
+ 0 ? false : true}
+ />
+ relationship?.customer?.tenantId === tenantId.value
+ )
+ ? false
+ : true
+ }
+ />
+ contact.customerId === tenantId.value
+ )
+ ? false
+ : true
+ }
+ />
+
+
+
+
+ >
+ )}
+
+
+ >
+ );
+};
+
+Page.getLayout = (page) => (
+
+ {page}
+
+);
+
+export default Page;
diff --git a/src/pages/tenant/gdap-management/onboarding/index.js b/src/pages/tenant/gdap-management/onboarding/index.js
deleted file mode 100644
index 61300826b823..000000000000
--- a/src/pages/tenant/gdap-management/onboarding/index.js
+++ /dev/null
@@ -1,71 +0,0 @@
-import { TabbedLayout } from "../../../../layouts/TabbedLayout";
-import { Layout as DashboardLayout } from "../../../../layouts/index.js";
-import tabOptions from "../tabOptions";
-import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx";
-import { Button } from "@mui/material";
-import Link from "next/link";
-import { Cancel, PlayArrow, Replay } from "@mui/icons-material";
-
-const pageTitle = "Tenant Onboarding";
-
-const actions = [
- {
- label: "Cancel Onboarding",
- type: "POST",
- url: "/api/ExecOnboardTenant",
- data: { id: "RowKey", Cancel: true },
- confirmText: "Are you sure you want to cancel these onboardings?",
- multiPost: false,
- icon: ,
- },
- {
- label: "Retry Onboarding",
- type: "POST",
- url: "/api/ExecOnboardTenant",
- data: { id: "RowKey", Retry: true },
- confirmText: "Are you sure you want to retry these onboardings?",
- multiPost: false,
- icon: ,
- },
-];
-
-const simpleColumns = [
- "Timestamp",
- "Relationship.customer.displayName",
- "Status",
- "OnboardingSteps",
- "Logs",
-];
-
-const apiUrl = "/api/ListTenantOnboarding";
-
-const Page = () => {
- return (
- }
- >
- Start Tenant Onboarding
-
- }
- maxHeightOffset="460px"
- />
- );
-};
-
-Page.getLayout = (page) => (
-
- {page}
-
-);
-
-export default Page;
diff --git a/src/pages/tenant/gdap-management/onboarding/index.jsx b/src/pages/tenant/gdap-management/onboarding/index.jsx
new file mode 100644
index 000000000000..ce4e51e84981
--- /dev/null
+++ b/src/pages/tenant/gdap-management/onboarding/index.jsx
@@ -0,0 +1,73 @@
+import { TabbedLayout } from "../../../../layouts/TabbedLayout";
+import { CippIcons } from "../../../../utils/icon-registry"
+import { Layout as DashboardLayout } from "../../../../layouts/index";
+import tabOptions from "../tabOptions";
+import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx";
+import { Button } from "@mui/material";
+import Link from "next/link";
+
+const pageTitle = "Tenant Onboarding";
+
+const actions = [
+ {
+ label: "Cancel Onboarding",
+ type: "POST",
+ url: "/api/ExecOnboardTenant",
+ data: { id: "RowKey", Cancel: true },
+ confirmText: "Are you sure you want to cancel these onboardings?",
+ multiPost: false,
+ icon: ,
+ },
+ {
+ label: "Retry Onboarding",
+ type: "POST",
+ url: "/api/ExecOnboardTenant",
+ data: { id: "RowKey", Retry: true },
+ confirmText: "Are you sure you want to retry these onboardings?",
+ multiPost: false,
+ icon: ,
+ },
+];
+
+const simpleColumns = [
+ "Timestamp",
+ "Relationship.customer.displayName",
+ "Status",
+ "OnboardingSteps",
+ "Logs",
+];
+
+const apiUrl = "/api/ListTenantOnboarding";
+
+const Page = () => {
+ return (
+ }
+ >
+ Start Tenant Onboarding
+
+ }
+ maxHeightOffset="460px"
+ />
+ );
+};
+
+Page.getLayout = (page) => (
+
+ {page}
+
+);
+
+export default Page;
diff --git a/src/pages/tenant/gdap-management/onboarding/start.js b/src/pages/tenant/gdap-management/onboarding/start.js
deleted file mode 100644
index 00c2f8a87d5b..000000000000
--- a/src/pages/tenant/gdap-management/onboarding/start.js
+++ /dev/null
@@ -1,598 +0,0 @@
-import {
- Accordion,
- AccordionDetails,
- AccordionSummary,
- Alert,
- Button,
- CardActions,
- CardContent,
- Typography,
-} from "@mui/material";
-import { Layout as DashboardLayout } from "../../../../layouts/index.js";
-import { useForm, useWatch } from "react-hook-form";
-import CippFormComponent from "../../../../components/CippComponents/CippFormComponent";
-import GDAPRoles from "../../../../data/GDAPRoles";
-import { Box, Stack } from "@mui/system";
-import { Grid } from "@mui/system";
-import { CippPropertyList } from "../../../../components/CippComponents/CippPropertyList";
-import { ApiGetCall, ApiGetCallWithPagination, ApiPostCall } from "../../../../api/ApiCall";
-import { useEffect, useState } from "react";
-import { getCippFormatting } from "../../../../utils/get-cipp-formatting";
-import { router } from "next/router";
-import cippDefaults from "../../../../data/CIPPDefaultGDAPRoles";
-import { WizardSteps } from "../../../../components/CippWizard/wizard-steps";
-import { ExpandMore, PlayArrow, Replay } from "@mui/icons-material";
-import CippPageCard from "../../../../components/CippCards/CippPageCard";
-import { getCippTranslation } from "../../../../utils/get-cipp-translation";
-import CippDataTableButton from "../../../../components/CippTable/CippDataTableButton";
-
-const Page = () => {
- const [currentRelationship, setCurrentRelationship] = useState(null);
- const [currentInvite, setCurrentInvite] = useState(null);
- const [rolesMissingFromMapping, setRolesMissingFromMapping] = useState([]);
- const [rolesMissingFromRelationship, setRolesMissingFromRelationship] = useState([]);
- const [missingDefaults, setMissingDefaults] = useState(false);
- const [currentOnboarding, setCurrentOnboarding] = useState(null);
- const [activeStep, setActiveStep] = useState(0);
- const [pollOnboarding, setPollOnboarding] = useState(false);
- const [showOnboardingStatus, setShowOnboardingStatus] = useState(false);
- const [invalidRelationship, setInvalidRelationship] = useState(false);
-
- const queryId = router.query.id;
- const formControl = useForm({
- mode: "onChange",
- });
-
- const currentInvites = ApiGetCallWithPagination({
- url: "/api/ListGDAPInvite",
- queryKey: "ListGDAPInvite",
- });
-
- const relationshipList = ApiGetCall({
- url: "/api/ListGDAPRelationships",
- queryKey: "GDAPRelationshipOnboarding",
- });
- const onboardingList = ApiGetCallWithPagination({
- url: "/api/ListTenantOnboarding",
- queryKey: "ListTenantOnboarding",
- });
-
- const startOnboarding = ApiPostCall({
- urlFromData: true,
- onResult: (data) => {
- setCurrentOnboarding(data);
- var stepCount = 0;
- data.OnboardingSteps.map((step) => {
- if (step.Status !== "pending" && step.Status !== "running" && step.Status !== "failed") {
- stepCount++;
- }
- });
- setActiveStep(stepCount);
-
- if (data?.Status === "succeeded" || data?.Status === "failed") {
- var runningSteps = data.OnboardingSteps?.find((step) => step.Status === "running");
- if (!runningSteps) {
- setPollOnboarding(false);
- }
- }
- },
- });
-
- const selectedRelationship = useWatch({
- control: formControl.control,
- name: "id",
- });
-
- const selectedRole = useWatch({
- control: formControl.control,
- name: "gdapRoles",
- });
-
- useEffect(() => {
- if (
- relationshipList.isSuccess &&
- currentInvites.isSuccess &&
- onboardingList.isSuccess &&
- selectedRelationship !== currentRelationship
- ) {
- var formValue = selectedRelationship;
- if (!selectedRelationship?.value && queryId) {
- var relationship = relationshipList?.data?.Results?.find(
- (relationship) => relationship?.id === queryId
- );
-
- if (
- relationship &&
- (relationship?.status === "active" || relationship?.status === "approvalPending") &&
- !relationship?.customer?.displayName.startsWith("MLT_")
- ) {
- formValue = {
- label:
- (relationship?.customer?.displayName ?? "Pending Invite") +
- " - (" +
- relationship?.id +
- ")",
- value: relationship?.id,
- addedFields: {
- customer: relationship?.customer,
- id: relationship?.id,
- createdDateTime: relationship?.createdDateTime,
- accessDetails: relationship?.accessDetails,
- status: relationship?.status,
- autoExtendDuration: relationship?.autoExtendDuration,
- lastModifiedDateTime: relationship?.lastModifiedDateTime,
- },
- };
- formControl.setValue("id", formValue);
- formControl.trigger();
- setInvalidRelationship(false);
- } else {
- setInvalidRelationship(true);
- }
- }
- const invite =
- currentInvites?.data?.pages?.[0] && Array.isArray(currentInvites.data.pages[0])
- ? currentInvites.data.pages[0].find((invite) => invite?.RowKey === formValue?.value)
- : null;
-
- const onboarding =
- onboardingList.data?.pages?.[0] && Array.isArray(onboardingList.data.pages[0])
- ? onboardingList.data.pages[0].find(
- (onboarding) => onboarding?.RowKey === formValue?.value
- )
- : null;
- if (onboarding) {
- setCurrentOnboarding(onboarding);
- var stepCount = 0;
- onboarding?.OnboardingSteps?.map((step) => {
- if (
- step?.Status !== "pending" &&
- step?.Status !== "running" &&
- step?.Status !== "failed"
- ) {
- stepCount++;
- }
- });
- setShowOnboardingStatus(true);
- setActiveStep(stepCount);
- } else if (currentOnboarding !== null) {
- setShowOnboardingStatus(false);
- setCurrentOnboarding(null);
- setActiveStep(0);
- }
- setCurrentRelationship(formValue);
- setCurrentInvite(invite ?? null);
- }
- }, [
- relationshipList.isSuccess,
- currentInvites.isSuccess,
- onboardingList.isSuccess,
- selectedRelationship,
- queryId,
- ]);
-
- useEffect(() => {
- if (currentRelationship?.value) {
- var currentRoles = [];
- if (currentInvite?.RoleMappings) {
- currentRoles = currentInvite?.RoleMappings;
- } else {
- currentRoles = selectedRole?.value;
- }
- var relationshipRoles = currentRelationship.addedFields.accessDetails.unifiedRoles;
- var missingRoles = [];
- var missingRolesRelationship = [];
-
- currentRoles?.forEach((role) => {
- if (
- !relationshipRoles?.find(
- (relationshipRole) => relationshipRole.roleDefinitionId === role.roleDefinitionId
- )
- ) {
- missingRoles.push(role);
- }
- });
-
- relationshipRoles?.forEach((role) => {
- if (
- !currentRoles?.find(
- (currentRole) => currentRole.roleDefinitionId === role.roleDefinitionId
- )
- ) {
- // lookup role from GDAPRoles
- var role = GDAPRoles?.find((gdapRole) => gdapRole.ObjectId === role.roleDefinitionId);
- missingRolesRelationship.push(role);
- }
- });
-
- var missingDefaults = [];
- cippDefaults.forEach((defaultRole) => {
- if (!relationshipRoles?.find((role) => defaultRole?.value === role?.roleDefinitionId)) {
- missingDefaults.push(defaultRole);
- }
- });
- setMissingDefaults(missingDefaults.length > 0);
- setRolesMissingFromMapping(missingRoles);
- setRolesMissingFromRelationship(missingRolesRelationship);
- setInvalidRelationship(false);
- }
- }, [selectedRole, currentInvite, currentRelationship]);
-
- useEffect(() => {
- // poll onboarding status
- if (pollOnboarding && startOnboarding.isSuccess) {
- const interval = setInterval(() => {
- startOnboarding.mutate({
- url: "/api/ExecOnboardTenant",
- data: {
- id: currentRelationship?.value,
- },
- });
- }, 5000);
- return () => clearInterval(interval);
- }
- }, [pollOnboarding, startOnboarding.isSuccess, startOnboarding?.data?.data]);
-
- const handleSubmit = () => {
- if (formControl.formState.errors.id) {
- return;
- }
- var data = {
- id: currentRelationship?.value,
- };
- if (!currentInvite) {
- data.autoMapRoles = true;
- data.gdapRoles = selectedRole?.value;
- }
- if (formControl.getValues("ignoreMissingRoles")) {
- data.ignoreMissingRoles = Boolean(formControl.getValues("ignoreMissingRoles"));
- }
- if (formControl.getValues("standardsExcludeAllTenants")) {
- data.standardsExcludeAllTenants = Boolean(
- formControl.getValues("standardsExcludeAllTenants")
- );
- }
-
- startOnboarding.mutate({
- url: "/api/ExecOnboardTenant",
- data: data,
- });
- setPollOnboarding(true);
- setShowOnboardingStatus(true);
- };
-
- const handleRetry = () => {
- if (formControl.formState.errors.id) {
- return;
- }
- var data = {
- id: currentRelationship?.value,
- retry: true,
- };
- if (!currentInvite) {
- data.autoMapRoles = true;
- data.gdapRoles = selectedRole?.value;
- }
- if (formControl.getValues("ignoreMissingRoles")) {
- data.IgnoreMissingRoles = Boolean(formControl.getValues("ignoreMissingRoles"));
- }
- if (formControl.getValues("standardsExcludeAllTenants")) {
- data.standardsExcludeAllTenants = Boolean(
- formControl.getValues("standardsExcludeAllTenants")
- );
- }
-
- startOnboarding.mutate({
- url: "/api/ExecOnboardTenant",
- data: data,
- });
- setPollOnboarding(true);
- };
-
- return (
- <>
-
-
-
-
-
-
- This page will allow you to start the onboarding process for a tenant. To proceed,
- select a GDAP Relationship from the dropdown below. If the relationship has not
- been mapped, you will be prompted to select a GDAP Role Template.
-
- {invalidRelationship && (
-
- The selected relationship ({queryId}) is not eligible for onboarding. Please
- select a different relationship.
-
- )}
-
- (option?.customer?.displayName ?? "Pending Invite") +
- " - (" +
- option?.id +
- ")",
- valueField: "id",
- addedField: {
- customer: "customer",
- id: "id",
- displayName: "displayName",
- createdDateTime: "createdDateTime",
- accessDetails: "accessDetails",
- status: "status",
- autoExtendDuration: "autoExtendDuration",
- lastModifiedDateTime: "lastModifiedDateTime",
- },
- dataFilter: (data) => {
- return data?.filter(
- (relationship) =>
- (relationship?.addedFields?.status === "active" ||
- relationship?.addedFields?.status === "approvalPending") &&
- !relationship?.addedFields?.displayName?.startsWith("MLT_")
- );
- },
- showRefresh: true,
- }}
- multiple={false}
- creatable={true}
- required={true}
- validators={{
- validate: (value) => {
- if (!value) {
- return "Please select a GDAP Relationship";
- }
- return true;
- },
- }}
- />
- {currentRelationship?.value && !currentInvite && (
- <>
-
- option?.TemplateId,
- valueField: "RoleMappings",
- }}
- required={true}
- validators={{
- validate: (value) => {
- if (!value) {
- return "Please select a GDAP Role Template";
- }
- return true;
- },
- }}
- multiple={false}
- creatable={false}
- />
- >
- )}
- {missingDefaults && (
- <>
-
- The selected relationship does not contain all the default roles. CIPP may not
- function correctly if this is the only relationship with the tenant.
- Onboarding will fail unless you ignore the missing default roles.
-
-
- >
- )}
-
- {currentRelationship?.value && (
- <>
- {currentRelationship?.addedFields?.accessDetails?.unifiedRoles.some(
- (role) => role.roleDefinitionId === "62e90394-69f5-4237-9190-012177145e10"
- ) && (
-
- The Global Administrator role is a highly privileged role that should be
- used with caution. GDAP Relationships with this role will not be eligible
- for auto-extend.
-
- )}
- {(currentInvite || selectedRole) && rolesMissingFromMapping.length > 0 && (
-
- The following roles are not available in the selected relationship and will
- not be mapped:{" "}
- {rolesMissingFromMapping.map((role) => role.RoleName).join(", ")}
-
- )}
- {(currentInvite || selectedRole) && rolesMissingFromRelationship.length > 0 && (
-
- The following roles are not mapped with the current template:{" "}
- {rolesMissingFromRelationship
- .map((role) => role?.Name ?? "Unknown Role")
- .join(", ")}
-
- )}
- {(currentInvite || selectedRole) &&
- rolesMissingFromMapping.length === 0 &&
- rolesMissingFromRelationship.length === 0 && (
- All roles are mapped correctly
- )}
-
- }>
- Current Relationship Details
-
-
-
-
-
- >
- )}
-
-
- {showOnboardingStatus && (
-
-
-
-
- Onboarding Status: {getCippTranslation(currentOnboarding?.Status)}
-
-
- Updated {getCippFormatting(currentOnboarding?.Timestamp, "Timestamp", "date")}
-
-
-
-
-
- ({
- title: step.Title,
- description: step.Message,
- error: step.Status === "failed",
- })) ?? []
- }
- />
-
-
- )}
-
-
-
-
- {currentOnboarding && (
- {
- formControl.trigger();
- handleRetry();
- }}
- startIcon={}
- disabled={!formControl.formState.isValid || currentOnboarding?.Status === "running"}
- >
- Retry
-
- )}
- {
- formControl.trigger();
- handleSubmit();
- }}
- startIcon={}
- disabled={
- !formControl.formState.isValid ||
- currentOnboarding?.Status === "succeeded" ||
- currentOnboarding?.Status === "failed" ||
- currentOnboarding?.Status === "queued" ||
- currentOnboarding?.Status === "running"
- }
- >
- Start
-
-
-
-
- >
- );
-};
-
-Page.getLayout = (page) => {page};
-
-export default Page;
diff --git a/src/pages/tenant/gdap-management/onboarding/start.jsx b/src/pages/tenant/gdap-management/onboarding/start.jsx
new file mode 100644
index 000000000000..bd76897cf003
--- /dev/null
+++ b/src/pages/tenant/gdap-management/onboarding/start.jsx
@@ -0,0 +1,598 @@
+import {
+ Accordion,
+ AccordionDetails,
+ AccordionSummary,
+ Alert,
+ Button,
+ CardActions,
+ CardContent,
+ Typography,
+} from "@mui/material";
+import { CippIcons } from "../../../../utils/icon-registry"
+import { Layout as DashboardLayout } from "../../../../layouts/index";
+import { useForm, useWatch } from "react-hook-form";
+import CippFormComponent from "../../../../components/CippComponents/CippFormComponent";
+import GDAPRoles from "../../../../data/GDAPRoles";
+import { Box, Stack } from "@mui/system";
+import { Grid } from "@mui/system";
+import { CippPropertyList } from "../../../../components/CippComponents/CippPropertyList";
+import { ApiGetCall, ApiGetCallWithPagination, ApiPostCall } from "../../../../api/ApiCall";
+import { useEffect, useState } from "react";
+import { getCippFormatting } from "../../../../utils/get-cipp-formatting";
+import { router } from "next/router";
+import cippDefaults from "../../../../data/CIPPDefaultGDAPRoles";
+import { WizardSteps } from "../../../../components/CippWizard/wizard-steps";
+import CippPageCard from "../../../../components/CippCards/CippPageCard";
+import { getCippTranslation } from "../../../../utils/get-cipp-translation";
+import CippDataTableButton from "../../../../components/CippTable/CippDataTableButton";
+
+const Page = () => {
+ const [currentRelationship, setCurrentRelationship] = useState(null);
+ const [currentInvite, setCurrentInvite] = useState(null);
+ const [rolesMissingFromMapping, setRolesMissingFromMapping] = useState([]);
+ const [rolesMissingFromRelationship, setRolesMissingFromRelationship] = useState([]);
+ const [missingDefaults, setMissingDefaults] = useState(false);
+ const [currentOnboarding, setCurrentOnboarding] = useState(null);
+ const [activeStep, setActiveStep] = useState(0);
+ const [pollOnboarding, setPollOnboarding] = useState(false);
+ const [showOnboardingStatus, setShowOnboardingStatus] = useState(false);
+ const [invalidRelationship, setInvalidRelationship] = useState(false);
+
+ const queryId = router.query.id;
+ const formControl = useForm({
+ mode: "onChange",
+ });
+
+ const currentInvites = ApiGetCallWithPagination({
+ url: "/api/ListGDAPInvite",
+ queryKey: "ListGDAPInvite",
+ });
+
+ const relationshipList = ApiGetCall({
+ url: "/api/ListGDAPRelationships",
+ queryKey: "GDAPRelationshipOnboarding",
+ });
+ const onboardingList = ApiGetCallWithPagination({
+ url: "/api/ListTenantOnboarding",
+ queryKey: "ListTenantOnboarding",
+ });
+
+ const startOnboarding = ApiPostCall({
+ urlFromData: true,
+ onResult: (data) => {
+ setCurrentOnboarding(data);
+ var stepCount = 0;
+ data.OnboardingSteps.map((step) => {
+ if (step.Status !== "pending" && step.Status !== "running" && step.Status !== "failed") {
+ stepCount++;
+ }
+ });
+ setActiveStep(stepCount);
+
+ if (data?.Status === "succeeded" || data?.Status === "failed") {
+ var runningSteps = data.OnboardingSteps?.find((step) => step.Status === "running");
+ if (!runningSteps) {
+ setPollOnboarding(false);
+ }
+ }
+ },
+ });
+
+ const selectedRelationship = useWatch({
+ control: formControl.control,
+ name: "id",
+ });
+
+ const selectedRole = useWatch({
+ control: formControl.control,
+ name: "gdapRoles",
+ });
+
+ useEffect(() => {
+ if (
+ relationshipList.isSuccess &&
+ currentInvites.isSuccess &&
+ onboardingList.isSuccess &&
+ selectedRelationship !== currentRelationship
+ ) {
+ var formValue = selectedRelationship;
+ if (!selectedRelationship?.value && queryId) {
+ var relationship = relationshipList?.data?.Results?.find(
+ (relationship) => relationship?.id === queryId
+ );
+
+ if (
+ relationship &&
+ (relationship?.status === "active" || relationship?.status === "approvalPending") &&
+ !relationship?.customer?.displayName.startsWith("MLT_")
+ ) {
+ formValue = {
+ label:
+ (relationship?.customer?.displayName ?? "Pending Invite") +
+ " - (" +
+ relationship?.id +
+ ")",
+ value: relationship?.id,
+ addedFields: {
+ customer: relationship?.customer,
+ id: relationship?.id,
+ createdDateTime: relationship?.createdDateTime,
+ accessDetails: relationship?.accessDetails,
+ status: relationship?.status,
+ autoExtendDuration: relationship?.autoExtendDuration,
+ lastModifiedDateTime: relationship?.lastModifiedDateTime,
+ },
+ };
+ formControl.setValue("id", formValue);
+ formControl.trigger();
+ setInvalidRelationship(false);
+ } else {
+ setInvalidRelationship(true);
+ }
+ }
+ const invite =
+ currentInvites?.data?.pages?.[0] && Array.isArray(currentInvites.data.pages[0])
+ ? currentInvites.data.pages[0].find((invite) => invite?.RowKey === formValue?.value)
+ : null;
+
+ const onboarding =
+ onboardingList.data?.pages?.[0] && Array.isArray(onboardingList.data.pages[0])
+ ? onboardingList.data.pages[0].find(
+ (onboarding) => onboarding?.RowKey === formValue?.value
+ )
+ : null;
+ if (onboarding) {
+ setCurrentOnboarding(onboarding);
+ var stepCount = 0;
+ onboarding?.OnboardingSteps?.map((step) => {
+ if (
+ step?.Status !== "pending" &&
+ step?.Status !== "running" &&
+ step?.Status !== "failed"
+ ) {
+ stepCount++;
+ }
+ });
+ setShowOnboardingStatus(true);
+ setActiveStep(stepCount);
+ } else if (currentOnboarding !== null) {
+ setShowOnboardingStatus(false);
+ setCurrentOnboarding(null);
+ setActiveStep(0);
+ }
+ setCurrentRelationship(formValue);
+ setCurrentInvite(invite ?? null);
+ }
+ }, [
+ relationshipList.isSuccess,
+ currentInvites.isSuccess,
+ onboardingList.isSuccess,
+ selectedRelationship,
+ queryId,
+ ]);
+
+ useEffect(() => {
+ if (currentRelationship?.value) {
+ var currentRoles = [];
+ if (currentInvite?.RoleMappings) {
+ currentRoles = currentInvite?.RoleMappings;
+ } else {
+ currentRoles = selectedRole?.value;
+ }
+ var relationshipRoles = currentRelationship.addedFields.accessDetails.unifiedRoles;
+ var missingRoles = [];
+ var missingRolesRelationship = [];
+
+ currentRoles?.forEach((role) => {
+ if (
+ !relationshipRoles?.find(
+ (relationshipRole) => relationshipRole.roleDefinitionId === role.roleDefinitionId
+ )
+ ) {
+ missingRoles.push(role);
+ }
+ });
+
+ relationshipRoles?.forEach((role) => {
+ if (
+ !currentRoles?.find(
+ (currentRole) => currentRole.roleDefinitionId === role.roleDefinitionId
+ )
+ ) {
+ // lookup role from GDAPRoles
+ var role = GDAPRoles?.find((gdapRole) => gdapRole.ObjectId === role.roleDefinitionId);
+ missingRolesRelationship.push(role);
+ }
+ });
+
+ var missingDefaults = [];
+ cippDefaults.forEach((defaultRole) => {
+ if (!relationshipRoles?.find((role) => defaultRole?.value === role?.roleDefinitionId)) {
+ missingDefaults.push(defaultRole);
+ }
+ });
+ setMissingDefaults(missingDefaults.length > 0);
+ setRolesMissingFromMapping(missingRoles);
+ setRolesMissingFromRelationship(missingRolesRelationship);
+ setInvalidRelationship(false);
+ }
+ }, [selectedRole, currentInvite, currentRelationship]);
+
+ useEffect(() => {
+ // poll onboarding status
+ if (pollOnboarding && startOnboarding.isSuccess) {
+ const interval = setInterval(() => {
+ startOnboarding.mutate({
+ url: "/api/ExecOnboardTenant",
+ data: {
+ id: currentRelationship?.value,
+ },
+ });
+ }, 5000);
+ return () => clearInterval(interval);
+ }
+ }, [pollOnboarding, startOnboarding.isSuccess, startOnboarding?.data?.data]);
+
+ const handleSubmit = () => {
+ if (formControl.formState.errors.id) {
+ return;
+ }
+ var data = {
+ id: currentRelationship?.value,
+ };
+ if (!currentInvite) {
+ data.autoMapRoles = true;
+ data.gdapRoles = selectedRole?.value;
+ }
+ if (formControl.getValues("ignoreMissingRoles")) {
+ data.ignoreMissingRoles = Boolean(formControl.getValues("ignoreMissingRoles"));
+ }
+ if (formControl.getValues("standardsExcludeAllTenants")) {
+ data.standardsExcludeAllTenants = Boolean(
+ formControl.getValues("standardsExcludeAllTenants")
+ );
+ }
+
+ startOnboarding.mutate({
+ url: "/api/ExecOnboardTenant",
+ data: data,
+ });
+ setPollOnboarding(true);
+ setShowOnboardingStatus(true);
+ };
+
+ const handleRetry = () => {
+ if (formControl.formState.errors.id) {
+ return;
+ }
+ var data = {
+ id: currentRelationship?.value,
+ retry: true,
+ };
+ if (!currentInvite) {
+ data.autoMapRoles = true;
+ data.gdapRoles = selectedRole?.value;
+ }
+ if (formControl.getValues("ignoreMissingRoles")) {
+ data.IgnoreMissingRoles = Boolean(formControl.getValues("ignoreMissingRoles"));
+ }
+ if (formControl.getValues("standardsExcludeAllTenants")) {
+ data.standardsExcludeAllTenants = Boolean(
+ formControl.getValues("standardsExcludeAllTenants")
+ );
+ }
+
+ startOnboarding.mutate({
+ url: "/api/ExecOnboardTenant",
+ data: data,
+ });
+ setPollOnboarding(true);
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+ This page will allow you to start the onboarding process for a tenant. To proceed,
+ select a GDAP Relationship from the dropdown below. If the relationship has not
+ been mapped, you will be prompted to select a GDAP Role Template.
+
+ {invalidRelationship && (
+
+ The selected relationship ({queryId}) is not eligible for onboarding. Please
+ select a different relationship.
+
+ )}
+
+ (option?.customer?.displayName ?? "Pending Invite") +
+ " - (" +
+ option?.id +
+ ")",
+ valueField: "id",
+ addedField: {
+ customer: "customer",
+ id: "id",
+ displayName: "displayName",
+ createdDateTime: "createdDateTime",
+ accessDetails: "accessDetails",
+ status: "status",
+ autoExtendDuration: "autoExtendDuration",
+ lastModifiedDateTime: "lastModifiedDateTime",
+ },
+ dataFilter: (data) => {
+ return data?.filter(
+ (relationship) =>
+ (relationship?.addedFields?.status === "active" ||
+ relationship?.addedFields?.status === "approvalPending") &&
+ !relationship?.addedFields?.displayName?.startsWith("MLT_")
+ );
+ },
+ showRefresh: true,
+ }}
+ multiple={false}
+ creatable={true}
+ required={true}
+ validators={{
+ validate: (value) => {
+ if (!value) {
+ return "Please select a GDAP Relationship";
+ }
+ return true;
+ },
+ }}
+ />
+ {currentRelationship?.value && !currentInvite && (
+ <>
+
+ option?.TemplateId,
+ valueField: "RoleMappings",
+ }}
+ required={true}
+ validators={{
+ validate: (value) => {
+ if (!value) {
+ return "Please select a GDAP Role Template";
+ }
+ return true;
+ },
+ }}
+ multiple={false}
+ creatable={false}
+ />
+ >
+ )}
+ {missingDefaults && (
+ <>
+
+ The selected relationship does not contain all the default roles. CIPP may not
+ function correctly if this is the only relationship with the tenant.
+ Onboarding will fail unless you ignore the missing default roles.
+
+
+ >
+ )}
+
+ {currentRelationship?.value && (
+ <>
+ {currentRelationship?.addedFields?.accessDetails?.unifiedRoles.some(
+ (role) => role.roleDefinitionId === "62e90394-69f5-4237-9190-012177145e10"
+ ) && (
+
+ The Global Administrator role is a highly privileged role that should be
+ used with caution. GDAP Relationships with this role will not be eligible
+ for auto-extend.
+
+ )}
+ {(currentInvite || selectedRole) && rolesMissingFromMapping.length > 0 && (
+
+ The following roles are not available in the selected relationship and will
+ not be mapped:{" "}
+ {rolesMissingFromMapping.map((role) => role.RoleName).join(", ")}
+
+ )}
+ {(currentInvite || selectedRole) && rolesMissingFromRelationship.length > 0 && (
+
+ The following roles are not mapped with the current template:{" "}
+ {rolesMissingFromRelationship
+ .map((role) => role?.Name ?? "Unknown Role")
+ .join(", ")}
+
+ )}
+ {(currentInvite || selectedRole) &&
+ rolesMissingFromMapping.length === 0 &&
+ rolesMissingFromRelationship.length === 0 && (
+ All roles are mapped correctly
+ )}
+
+ }>
+ Current Relationship Details
+
+
+
+
+
+ >
+ )}
+
+
+ {showOnboardingStatus && (
+
+
+
+
+ Onboarding Status: {getCippTranslation(currentOnboarding?.Status)}
+
+
+ Updated {getCippFormatting(currentOnboarding?.Timestamp, "Timestamp", "date")}
+
+
+
+
+
+ ({
+ title: step.Title,
+ description: step.Message,
+ error: step.Status === "failed",
+ })) ?? []
+ }
+ />
+
+
+ )}
+
+
+
+
+ {currentOnboarding && (
+ {
+ formControl.trigger();
+ handleRetry();
+ }}
+ startIcon={}
+ disabled={!formControl.formState.isValid || currentOnboarding?.Status === "running"}
+ >
+ Retry
+
+ )}
+ {
+ formControl.trigger();
+ handleSubmit();
+ }}
+ startIcon={}
+ disabled={
+ !formControl.formState.isValid ||
+ currentOnboarding?.Status === "succeeded" ||
+ currentOnboarding?.Status === "failed" ||
+ currentOnboarding?.Status === "queued" ||
+ currentOnboarding?.Status === "running"
+ }
+ >
+ Start
+
+
+
+
+ >
+ );
+};
+
+Page.getLayout = (page) => {page};
+
+export default Page;
diff --git a/src/pages/tenant/gdap-management/relationships/index.js b/src/pages/tenant/gdap-management/relationships/index.js
deleted file mode 100644
index f3a3e4f441a7..000000000000
--- a/src/pages/tenant/gdap-management/relationships/index.js
+++ /dev/null
@@ -1,72 +0,0 @@
-import { TabbedLayout } from "../../../../layouts/TabbedLayout";
-import { Layout as DashboardLayout } from "../../../../layouts/index.js";
-import tabOptions from "../tabOptions";
-import CippTablePage from "../../../../components/CippComponents/CippTablePage";
-import CippGdapActions from "../../../../components/CippComponents/CippGdapActions";
-
-const actions = CippGdapActions();
-
-const simpleColumns = [
- "customer.displayName",
- "displayName",
- "status",
- "createdDateTime",
- "activatedDateTime",
- "endDateTime",
- "autoExtendDuration",
- "accessDetails.unifiedRoles",
-];
-
-const filters = [
- {
- filterName: "Active",
- value: [{ id: "status", value: "active" }],
- type: "column",
- },
- {
- filterName: "Approval Pending",
- value: [{ id: "status", value: "approvalPending" }],
- type: "column",
- },
- {
- filterName: "Terminating",
- value: [{ id: "status", value: "terminating" }],
- type: "column",
- },
- {
- filterName: "Terminated",
- value: [{ id: "status", value: "terminated" }],
- type: "column",
- },
-];
-
-const offCanvas = {
- actions: actions,
- extendedInfoFields: simpleColumns,
-};
-
-const Page = () => {
- return (
-
- );
-};
-
-Page.getLayout = (page) => (
-
- {page}
-
-);
-
-export default Page;
diff --git a/src/pages/tenant/gdap-management/relationships/index.jsx b/src/pages/tenant/gdap-management/relationships/index.jsx
new file mode 100644
index 000000000000..0f772c44c89f
--- /dev/null
+++ b/src/pages/tenant/gdap-management/relationships/index.jsx
@@ -0,0 +1,76 @@
+import { TabbedLayout } from "../../../../layouts/TabbedLayout";
+import { Layout as DashboardLayout } from "../../../../layouts/index";
+import tabOptions from "../tabOptions";
+import CippTablePage from "../../../../components/CippComponents/CippTablePage";
+import CippGdapActions from "../../../../components/CippComponents/CippGdapActions";
+
+const actions = CippGdapActions();
+
+const simpleColumns = [
+ "customer.displayName",
+ "displayName",
+ "status",
+ "createdDateTime",
+ "activatedDateTime",
+ "endDateTime",
+ "autoExtendDuration",
+ "accessDetails.unifiedRoles",
+];
+
+const filters = [
+ {
+ filterName: "Active",
+ value: [{ id: "status", value: "active" }],
+ type: "column",
+ },
+ {
+ filterName: "Approval Pending",
+ value: [{ id: "status", value: "approvalPending" }],
+ type: "column",
+ },
+ {
+ filterName: "Terminating",
+ value: [{ id: "status", value: "terminating" }],
+ type: "column",
+ },
+ {
+ filterName: "Terminated",
+ value: [{ id: "status", value: "terminated" }],
+ type: "column",
+ },
+];
+
+const offCanvas = {
+ actions: actions,
+ extendedInfoFields: simpleColumns,
+};
+
+const Page = () => {
+ return (
+ Boolean(row?.id),
+ }}
+ simpleColumns={simpleColumns}
+ maxHeightOffset="460px"
+ filters={filters}
+ defaultSorting={[{ id: "customer.displayName", desc: false }]}
+ />
+ );
+};
+
+Page.getLayout = (page) => (
+
+ {page}
+
+);
+
+export default Page;
diff --git a/src/pages/tenant/gdap-management/relationships/relationship/index.js b/src/pages/tenant/gdap-management/relationships/relationship/index.js
deleted file mode 100644
index df4c3f3004ca..000000000000
--- a/src/pages/tenant/gdap-management/relationships/relationship/index.js
+++ /dev/null
@@ -1,218 +0,0 @@
-import { Layout as DashboardLayout } from "../../../../../layouts/index.js";
-import { useRouter } from "next/router";
-import { ApiGetCall } from "../../../../../api/ApiCall";
-import CippFormSkeleton from "../../../../../components/CippFormPages/CippFormSkeleton";
-import { HeaderedTabbedLayout } from "../../../../../layouts/HeaderedTabbedLayout";
-import { CippGdapRelationshipSwitcher } from "../../../../../components/CippComponents/CippGdapRelationshipSwitcher";
-import tabOptions from "./tabOptions.json";
-import { Box, Grid, Stack } from "@mui/system";
-import { CippTimeAgo } from "../../../../../components/CippComponents/CippTimeAgo";
-import { getCippTranslation } from "../../../../../utils/get-cipp-translation";
-import { CippPropertyListCard } from "../../../../../components/CippCards/CippPropertyListCard";
-import { getCippFormatting } from "../../../../../utils/get-cipp-formatting";
-import { CippDataTable } from "../../../../../components/CippTable/CippDataTable";
-import { Alert, Link } from "@mui/material";
-import CIPPDefaultGDAPRoles from "../../../../../data/CIPPDefaultGDAPRoles.json";
-import { CippCopyToClipBoard } from "../../../../../components/CippComponents/CippCopyToClipboard";
-import { Schedule } from "@mui/icons-material";
-import { useEffect, useState } from "react";
-import CippGdapActions from "../../../../../components/CippComponents/CippGdapActions";
-
-const Page = () => {
- const router = useRouter();
- const { id } = router.query;
- const [relationshipProperties, setRelationshipProperties] = useState([]);
- const [relationshipData, setRelationshipData] = useState({});
-
- const relationshipRequest = ApiGetCall({
- url: `/api/ListGDAPRelationships?id=${id}`,
- queryKey: `ListRelationships-${id}`,
- });
-
- const getRelationshipType = (relationshipName) => {
- if (relationshipName.startsWith("MLT_")) {
- return "Microsoft-Led Transition (MLT)";
- } else if (
- relationshipName.startsWith("CIPP_") ||
- relationshipName.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
- ) {
- return "CIPP";
- } else if (relationshipName.startsWith("LHSetup_")) {
- return "Lighthouse";
- } else {
- return "Manual";
- }
- };
-
- // Set the title and subtitle for the layout
- const title = relationshipRequest.isSuccess
- ? (relationshipRequest.data?.Results?.[0]?.customer?.displayName ?? "No Customer Set") +
- " - " +
- relationshipRequest.data?.Results?.[0]?.displayName
- : "Loading...";
-
- const subtitle = relationshipRequest.isSuccess
- ? [
- {
- icon: ,
- text: (
- <>
- Created{" "}
- {" "}
- >
- ),
- },
- ]
- : [];
-
- useEffect(() => {
- if (relationshipRequest.isSuccess) {
- const data = relationshipRequest?.data?.Results?.[0];
- setRelationshipData(data);
- var properties = [
- {
- label: "Customer",
- value: data?.customer?.displayName ?? "N/A",
- },
- {
- label: "Tenant ID",
- value: data?.customer?.tenantId ?? "N/A",
- },
- {
- label: "Relationship Type",
- value: getRelationshipType(data?.displayName),
- },
- {
- label: "Relationship ID",
- value: (
- <>
- {data?.id}
-
- >
- ),
- },
- {
- label: "Status",
- value: getCippTranslation(data?.status, "status"),
- },
- {
- label: "Auto Extend Duration",
- value:
- data?.autoExtendDuration == "PT0S"
- ? "Not eligible for auto-extend"
- : getCippFormatting(data?.autoExtendDuration, "autoExtendDuration", "text"),
- },
- {
- label: "Activated Date",
- value: getCippFormatting(data?.activatedDateTime, "activatedDateTime", "date"),
- },
- {
- label: "Last Modified Date",
- value: getCippFormatting(data?.lastModifiedDateTime, "lastModifiedDateTime", "date"),
- },
- {
- label: "End Date",
- value: getCippFormatting(data?.endDateTime, "endDateTime", "date"),
- },
- ];
- if (data?.status === "approvalPending") {
- properties.push({
- label: "Invite URL",
- value: getCippFormatting(
- "https://admin.cloud.microsoft/?#/partners/invitation/granularAdminRelationships/" +
- data?.id,
- "InviteUrl",
- "url"
- ),
- });
- }
- setRelationshipProperties(properties);
- }
- }, [relationshipRequest.isSuccess]);
-
- return (
- }
- subtitle={subtitle}
- isFetching={relationshipRequest.isLoading}
- actions={CippGdapActions()}
- actionsData={relationshipData}
- backUrl="/tenant/gdap-management/relationships"
- >
- {relationshipRequest.isLoading && }
- {relationshipRequest.isSuccess && (
-
-
- {relationshipRequest?.data?.Results?.[0]?.displayName.startsWith("MLT_") && (
-
- This relationship is a Microsoft-Led Transition (MLT) relationship and only has Read
- permissions.
-
- )}
- {/* create alert for relationship with global administrator */}
- {relationshipRequest?.data?.Results?.[0]?.accessDetails?.unifiedRoles?.find((role) => {
- return role.roleDefinitionId === "62e90394-69f5-4237-9190-012177145e10";
- }) && (
-
- This relationship has Global Administrator access and is not eligible for automatic
- extension.
-
- )}
- {CIPPDefaultGDAPRoles.every((role) =>
- relationshipRequest?.data?.Results?.[0]?.accessDetails?.unifiedRoles?.some(
- (relationshipRole) => relationshipRole.roleDefinitionId === role.value
- )
- ) ? (
-
- This relationship has all the CIPP recommended roles.
-
- ) : (
-
- This relationship does not have all the CIPP recommended roles. See the{" "}
-
- Recommended Roles
- {" "}
- documentation for more information.
-
- )}
-
-
-
-
-
-
-
-
-
-
- )}
-
- );
-};
-
-Page.getLayout = (page) => {page};
-
-export default Page;
diff --git a/src/pages/tenant/gdap-management/relationships/relationship/index.jsx b/src/pages/tenant/gdap-management/relationships/relationship/index.jsx
new file mode 100644
index 000000000000..801ecbe63e11
--- /dev/null
+++ b/src/pages/tenant/gdap-management/relationships/relationship/index.jsx
@@ -0,0 +1,218 @@
+import { Layout as DashboardLayout } from "../../../../../layouts/index";
+import { CippIcons } from "../../../../../utils/icon-registry"
+import { useRouter } from "next/router";
+import { ApiGetCall } from "../../../../../api/ApiCall";
+import CippFormSkeleton from "../../../../../components/CippFormPages/CippFormSkeleton";
+import { HeaderedTabbedLayout } from "../../../../../layouts/HeaderedTabbedLayout";
+import { CippGdapRelationshipSwitcher } from "../../../../../components/CippComponents/CippGdapRelationshipSwitcher";
+import tabOptions from "./tabOptions.json";
+import { Box, Grid, Stack } from "@mui/system";
+import { CippTimeAgo } from "../../../../../components/CippComponents/CippTimeAgo";
+import { getCippTranslation } from "../../../../../utils/get-cipp-translation";
+import { CippPropertyListCard } from "../../../../../components/CippCards/CippPropertyListCard";
+import { getCippFormatting } from "../../../../../utils/get-cipp-formatting";
+import { CippDataTable } from "../../../../../components/CippTable/CippDataTable";
+import { Alert, Link } from "@mui/material";
+import CIPPDefaultGDAPRoles from "../../../../../data/CIPPDefaultGDAPRoles.json";
+import { CippCopyToClipBoard } from "../../../../../components/CippComponents/CippCopyToClipboard";
+import { useEffect, useState } from "react";
+import CippGdapActions from "../../../../../components/CippComponents/CippGdapActions";
+
+const Page = () => {
+ const router = useRouter();
+ const { id } = router.query;
+ const [relationshipProperties, setRelationshipProperties] = useState([]);
+ const [relationshipData, setRelationshipData] = useState({});
+
+ const relationshipRequest = ApiGetCall({
+ url: `/api/ListGDAPRelationships?id=${id}`,
+ queryKey: `ListRelationships-${id}`,
+ });
+
+ const getRelationshipType = (relationshipName) => {
+ if (relationshipName.startsWith("MLT_")) {
+ return "Microsoft-Led Transition (MLT)";
+ } else if (
+ relationshipName.startsWith("CIPP_") ||
+ relationshipName.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
+ ) {
+ return "CIPP";
+ } else if (relationshipName.startsWith("LHSetup_")) {
+ return "Lighthouse";
+ } else {
+ return "Manual";
+ }
+ };
+
+ // Set the title and subtitle for the layout
+ const title = relationshipRequest.isSuccess
+ ? (relationshipRequest.data?.Results?.[0]?.customer?.displayName ?? "No Customer Set") +
+ " - " +
+ relationshipRequest.data?.Results?.[0]?.displayName
+ : "Loading...";
+
+ const subtitle = relationshipRequest.isSuccess
+ ? [
+ {
+ icon: ,
+ text: (
+ <>
+ Created{" "}
+ {" "}
+ >
+ ),
+ },
+ ]
+ : [];
+
+ useEffect(() => {
+ if (relationshipRequest.isSuccess) {
+ const data = relationshipRequest?.data?.Results?.[0];
+ setRelationshipData(data);
+ var properties = [
+ {
+ label: "Customer",
+ value: data?.customer?.displayName ?? "N/A",
+ },
+ {
+ label: "Tenant ID",
+ value: data?.customer?.tenantId ?? "N/A",
+ },
+ {
+ label: "Relationship Type",
+ value: getRelationshipType(data?.displayName),
+ },
+ {
+ label: "Relationship ID",
+ value: (
+ <>
+ {data?.id}
+
+ >
+ ),
+ },
+ {
+ label: "Status",
+ value: getCippTranslation(data?.status, "status"),
+ },
+ {
+ label: "Auto Extend Duration",
+ value:
+ data?.autoExtendDuration == "PT0S"
+ ? "Not eligible for auto-extend"
+ : getCippFormatting(data?.autoExtendDuration, "autoExtendDuration", "text"),
+ },
+ {
+ label: "Activated Date",
+ value: getCippFormatting(data?.activatedDateTime, "activatedDateTime", "date"),
+ },
+ {
+ label: "Last Modified Date",
+ value: getCippFormatting(data?.lastModifiedDateTime, "lastModifiedDateTime", "date"),
+ },
+ {
+ label: "End Date",
+ value: getCippFormatting(data?.endDateTime, "endDateTime", "date"),
+ },
+ ];
+ if (data?.status === "approvalPending") {
+ properties.push({
+ label: "Invite URL",
+ value: getCippFormatting(
+ "https://admin.cloud.microsoft/?#/partners/invitation/granularAdminRelationships/" +
+ data?.id,
+ "InviteUrl",
+ "url"
+ ),
+ });
+ }
+ setRelationshipProperties(properties);
+ }
+ }, [relationshipRequest.isSuccess]);
+
+ return (
+ }
+ subtitle={subtitle}
+ isFetching={relationshipRequest.isLoading}
+ actions={CippGdapActions()}
+ actionsData={relationshipData}
+ backUrl="/tenant/gdap-management/relationships"
+ >
+ {relationshipRequest.isLoading && }
+ {relationshipRequest.isSuccess && (
+
+
+ {relationshipRequest?.data?.Results?.[0]?.displayName.startsWith("MLT_") && (
+
+ This relationship is a Microsoft-Led Transition (MLT) relationship and only has Read
+ permissions.
+
+ )}
+ {/* create alert for relationship with global administrator */}
+ {relationshipRequest?.data?.Results?.[0]?.accessDetails?.unifiedRoles?.find((role) => {
+ return role.roleDefinitionId === "62e90394-69f5-4237-9190-012177145e10";
+ }) && (
+
+ This relationship has Global Administrator access and is not eligible for automatic
+ extension.
+
+ )}
+ {CIPPDefaultGDAPRoles.every((role) =>
+ relationshipRequest?.data?.Results?.[0]?.accessDetails?.unifiedRoles?.some(
+ (relationshipRole) => relationshipRole.roleDefinitionId === role.value
+ )
+ ) ? (
+
+ This relationship has all the CIPP recommended roles.
+
+ ) : (
+
+ This relationship does not have all the CIPP recommended roles. See the{" "}
+
+ Recommended Roles
+ {" "}
+ documentation for more information.
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+Page.getLayout = (page) => {page};
+
+export default Page;
diff --git a/src/pages/tenant/gdap-management/relationships/relationship/mappings.js b/src/pages/tenant/gdap-management/relationships/relationship/mappings.js
deleted file mode 100644
index 383dfc279451..000000000000
--- a/src/pages/tenant/gdap-management/relationships/relationship/mappings.js
+++ /dev/null
@@ -1,73 +0,0 @@
-import { Layout as DashboardLayout } from "../../../../../layouts/index.js";
-import { useRouter } from "next/router";
-import { ApiGetCall } from "../../../../../api/ApiCall";
-import { HeaderedTabbedLayout } from "../../../../../layouts/HeaderedTabbedLayout";
-import { CippGdapRelationshipSwitcher } from "../../../../../components/CippComponents/CippGdapRelationshipSwitcher";
-import tabOptions from "./tabOptions.json";
-import { CippTimeAgo } from "../../../../../components/CippComponents/CippTimeAgo";
-import { CippDataTable } from "../../../../../components/CippTable/CippDataTable";
-import { Schedule } from "@mui/icons-material";
-
-const Page = () => {
- const router = useRouter();
- const { id } = router.query;
-
- const relationshipRequest = ApiGetCall({
- url: `/api/ListGDAPRelationships?id=${id}`,
- queryKey: `ListRelationships-${id}`,
- });
-
- // Set the title and subtitle for the layout
- const title = relationshipRequest.isSuccess
- ? relationshipRequest.data?.Results?.[0]?.customer?.displayName +
- " - " +
- relationshipRequest.data?.Results?.[0]?.displayName
- : "Loading...";
-
- const subtitle = relationshipRequest.isSuccess
- ? [
- {
- icon: ,
- text: (
- <>
- Created{" "}
- {" "}
- >
- ),
- },
- ]
- : [];
-
- const data = relationshipRequest?.data?.Results?.[0];
-
- return (
- }
- subtitle={subtitle}
- isFetching={relationshipRequest.isLoading}
- backUrl="/tenant/gdap-management/relationships"
- >
- {id && (
-
- )}
-
- );
-};
-
-Page.getLayout = (page) => {page};
-
-export default Page;
diff --git a/src/pages/tenant/gdap-management/relationships/relationship/mappings.jsx b/src/pages/tenant/gdap-management/relationships/relationship/mappings.jsx
new file mode 100644
index 000000000000..93b974e16422
--- /dev/null
+++ b/src/pages/tenant/gdap-management/relationships/relationship/mappings.jsx
@@ -0,0 +1,73 @@
+import { Layout as DashboardLayout } from "../../../../../layouts/index";
+import { CippIcons } from "../../../../../utils/icon-registry"
+import { useRouter } from "next/router";
+import { ApiGetCall } from "../../../../../api/ApiCall";
+import { HeaderedTabbedLayout } from "../../../../../layouts/HeaderedTabbedLayout";
+import { CippGdapRelationshipSwitcher } from "../../../../../components/CippComponents/CippGdapRelationshipSwitcher";
+import tabOptions from "./tabOptions.json";
+import { CippTimeAgo } from "../../../../../components/CippComponents/CippTimeAgo";
+import { CippDataTable } from "../../../../../components/CippTable/CippDataTable";
+
+const Page = () => {
+ const router = useRouter();
+ const { id } = router.query;
+
+ const relationshipRequest = ApiGetCall({
+ url: `/api/ListGDAPRelationships?id=${id}`,
+ queryKey: `ListRelationships-${id}`,
+ });
+
+ // Set the title and subtitle for the layout
+ const title = relationshipRequest.isSuccess
+ ? relationshipRequest.data?.Results?.[0]?.customer?.displayName +
+ " - " +
+ relationshipRequest.data?.Results?.[0]?.displayName
+ : "Loading...";
+
+ const subtitle = relationshipRequest.isSuccess
+ ? [
+ {
+ icon: ,
+ text: (
+ <>
+ Created{" "}
+ {" "}
+ >
+ ),
+ },
+ ]
+ : [];
+
+ const data = relationshipRequest?.data?.Results?.[0];
+
+ return (
+ }
+ subtitle={subtitle}
+ isFetching={relationshipRequest.isLoading}
+ backUrl="/tenant/gdap-management/relationships"
+ >
+ {id && (
+
+ )}
+
+ );
+};
+
+Page.getLayout = (page) => {page};
+
+export default Page;
diff --git a/src/pages/tenant/gdap-management/role-templates/add.js b/src/pages/tenant/gdap-management/role-templates/add.js
deleted file mode 100644
index 3d3652e40d64..000000000000
--- a/src/pages/tenant/gdap-management/role-templates/add.js
+++ /dev/null
@@ -1,44 +0,0 @@
-import CippFormPage from "../../../../components/CippFormPages/CippFormPage";
-import { Layout as DashboardLayout } from "../../../../layouts/index.js";
-import { useForm } from "react-hook-form";
-import { CippAddEditGdapRoleTemplate } from "../../../../components/CippFormPages/CippAddEditGdapRoleTemplate";
-import { ApiGetCall } from "../../../../api/ApiCall";
-
-const Page = () => {
- const formControl = useForm({
- mode: "onChange",
- });
- const availableRoles = ApiGetCall({
- url: "/api/ListGDAPRoles",
- queryKey: "ListGDAPRolesAutocomplete",
- });
- return (
- <>
- {
- var newRoleMappings = [];
- values.roleMappings.map((roleMapping) => {
- var role = availableRoles.data.find((role) => role.GroupId === roleMapping.value);
- newRoleMappings.push(role);
- });
- const shippedValues = {
- templateId: values.templateId,
- roleMappings: newRoleMappings,
- };
- return shippedValues;
- }}
- >
-
-
- >
- );
-};
-
-Page.getLayout = (page) => {page};
-
-export default Page;
diff --git a/src/pages/tenant/gdap-management/role-templates/add.jsx b/src/pages/tenant/gdap-management/role-templates/add.jsx
new file mode 100644
index 000000000000..9e823e668e25
--- /dev/null
+++ b/src/pages/tenant/gdap-management/role-templates/add.jsx
@@ -0,0 +1,34 @@
+import CippFormPage from "../../../../components/CippFormPages/CippFormPage";
+import { Layout as DashboardLayout } from "../../../../layouts/index";
+import { useForm } from "react-hook-form";
+import { CippAddEditGdapRoleTemplate } from "../../../../components/CippFormPages/CippAddEditGdapRoleTemplate";
+import { ApiGetCall } from "../../../../api/ApiCall";
+import { buildGdapTemplatePayload } from "../../../../utils/gdap-role-options";
+
+const Page = () => {
+ const formControl = useForm({
+ mode: "onChange",
+ });
+ const availableRoles = ApiGetCall({
+ url: "/api/ListGDAPRoles?validate=true",
+ queryKey: "ListGDAPRolesAutocomplete",
+ });
+ return (
+ <>
+ buildGdapTemplatePayload(values, availableRoles.data)}
+ >
+
+
+ >
+ );
+};
+
+Page.getLayout = (page) => {page};
+
+export default Page;
diff --git a/src/pages/tenant/gdap-management/role-templates/edit.js b/src/pages/tenant/gdap-management/role-templates/edit.js
deleted file mode 100644
index 24623ef563b8..000000000000
--- a/src/pages/tenant/gdap-management/role-templates/edit.js
+++ /dev/null
@@ -1,75 +0,0 @@
-import CippFormPage from "../../../../components/CippFormPages/CippFormPage";
-import { Layout as DashboardLayout } from "../../../../layouts/index.js";
-import { useForm } from "react-hook-form";
-import { CippAddEditGdapRoleTemplate } from "../../../../components/CippFormPages/CippAddEditGdapRoleTemplate";
-import { ApiGetCall } from "../../../../api/ApiCall";
-import { useEffect } from "react";
-import { useRouter } from "next/router";
-import { ApiGetCallWithPagination } from "../../../../api/ApiCall";
-
-const Page = () => {
- const router = useRouter();
- const { templateId } = router.query;
- const formControl = useForm({
- mode: "onChange",
- });
- const availableRoles = ApiGetCall({
- url: "/api/ListGDAPRoles",
- queryKey: "ListGDAPRolesAutocomplete",
- });
-
- const availableTemplates = ApiGetCallWithPagination({
- url: `/api/ExecGDAPRoleTemplate`,
- queryKey: `ListGDAPRoleTemplates`,
- });
-
- useEffect(() => {
- if (availableTemplates.isSuccess) {
- const template = availableTemplates?.data?.pages?.[0]?.Results.find(
- (template) => template.TemplateId === templateId
- );
- var newRoleMappings = [];
- template.RoleMappings.map((roleMapping) =>
- newRoleMappings.push({
- label: roleMapping.GroupName,
- value: roleMapping.GroupId,
- })
- );
- formControl.reset({
- templateId: template.TemplateId,
- roleMappings: newRoleMappings,
- });
- }
- }, [availableTemplates.isSuccess, availableTemplates.data]);
-
- return (
- <>
- {
- var newRoleMappings = [];
- values.roleMappings.map((roleMapping) => {
- var role = availableRoles.data.find((role) => role.GroupId === roleMapping.value);
- newRoleMappings.push(role);
- });
- const shippedValues = {
- originalTemplateId: templateId, // Pass the original template ID
- templateId: values.templateId,
- roleMappings: newRoleMappings,
- };
- return shippedValues;
- }}
- >
-
-
- >
- );
-};
-
-Page.getLayout = (page) => {page};
-
-export default Page;
diff --git a/src/pages/tenant/gdap-management/role-templates/edit.jsx b/src/pages/tenant/gdap-management/role-templates/edit.jsx
new file mode 100644
index 000000000000..e374423f326b
--- /dev/null
+++ b/src/pages/tenant/gdap-management/role-templates/edit.jsx
@@ -0,0 +1,97 @@
+import CippFormPage from "../../../../components/CippFormPages/CippFormPage";
+import { Layout as DashboardLayout } from "../../../../layouts/index";
+import { useForm } from "react-hook-form";
+import { CippAddEditGdapRoleTemplate } from "../../../../components/CippFormPages/CippAddEditGdapRoleTemplate";
+import { ApiGetCall } from "../../../../api/ApiCall";
+import { useEffect, useState } from "react";
+import { useRouter } from "next/router";
+import { ApiGetCallWithPagination } from "../../../../api/ApiCall";
+import { Alert } from "@mui/material";
+import GDAPRoles from "../../../../data/GDAPRoles";
+import {
+ buildGdapTemplatePayload,
+ buildGdapTemplateSelection,
+} from "../../../../utils/gdap-role-options";
+
+const Page = () => {
+ const router = useRouter();
+ const { templateId } = router.query;
+ const [extraOptions, setExtraOptions] = useState([]);
+ const [mixedSuffixes, setMixedSuffixes] = useState(false);
+ const formControl = useForm({
+ mode: "onChange",
+ });
+ const availableRoles = ApiGetCall({
+ url: "/api/ListGDAPRoles?validate=true",
+ queryKey: "ListGDAPRolesAutocomplete",
+ });
+
+ const availableTemplates = ApiGetCallWithPagination({
+ url: `/api/ExecGDAPRoleTemplate`,
+ queryKey: `ListGDAPRoleTemplates`,
+ });
+
+ useEffect(() => {
+ if (availableTemplates.isSuccess && availableRoles.isSuccess) {
+ const template = availableTemplates?.data?.pages?.[0]?.Results.find(
+ (template) => template.TemplateId === templateId
+ );
+ // No templateId, or it no longer matches a template: nothing to populate.
+ if (!template) {
+ return;
+ }
+ const selection = buildGdapTemplateSelection(
+ template.RoleMappings,
+ GDAPRoles,
+ availableRoles.data
+ );
+ setExtraOptions(selection.extraOptions);
+ setMixedSuffixes(selection.mixedSuffixes);
+ formControl.reset({
+ templateId: template.TemplateId,
+ roleMappings: selection.selected,
+ customSuffix: selection.customSuffix ?? "",
+ });
+ }
+ }, [
+ availableTemplates.isSuccess,
+ availableTemplates.data,
+ availableRoles.isSuccess,
+ availableRoles.data,
+ ]);
+
+ return (
+ <>
+
+ buildGdapTemplatePayload(values, availableRoles.data, templateId)
+ }
+ hideSubmit={!templateId}
+ >
+ {!templateId && (
+
+ No template selected. Open this page from the GDAP Role Templates list to edit a
+ template.
+
+ )}
+ {templateId && (
+
+ )}
+
+ >
+ );
+};
+
+Page.getLayout = (page) => {page};
+
+export default Page;
diff --git a/src/pages/tenant/gdap-management/role-templates/index.js b/src/pages/tenant/gdap-management/role-templates/index.js
deleted file mode 100644
index 9364a937a96e..000000000000
--- a/src/pages/tenant/gdap-management/role-templates/index.js
+++ /dev/null
@@ -1,121 +0,0 @@
-import { TabbedLayout } from "../../../../layouts/TabbedLayout";
-import { Layout as DashboardLayout } from "../../../../layouts/index.js";
-import tabOptions from "../tabOptions";
-import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx";
-import { Alert, Button, SvgIcon } from "@mui/material";
-import Link from "next/link";
-import { ApiGetCallWithPagination, ApiPostCall } from "../../../../api/ApiCall";
-import { useEffect, useState } from "react";
-import { Box, Stack } from "@mui/system";
-import { PlusIcon, TrashIcon } from "@heroicons/react/24/outline";
-import { CippApiResults } from "../../../../components/CippComponents/CippApiResults";
-import { Edit, AddBox } from "@mui/icons-material";
-
-const Page = () => {
- const pageTitle = "GDAP Role Templates";
- const [createDefaults, setCreateDefaults] = useState(false);
- const actions = [
- {
- label: "Edit Template",
- link: "/tenant/gdap-management/role-templates/edit?templateId=[TemplateId]",
- icon: ,
- },
- {
- label: "Delete Template",
- url: "/api/ExecGDAPRoleTemplate?Action=Delete",
- type: "POST",
- icon: ,
- data: { TemplateId: "TemplateId" },
- confirmText: "Are you sure you want to delete this Role Template?",
- },
- ];
-
- const simpleColumns = ["TemplateId", "RoleMappings"];
- const apiUrl = "/api/ExecGDAPRoleTemplate";
-
- const currentTemplates = ApiGetCallWithPagination({
- url: apiUrl,
- queryKey: "ListGDAPRoleTemplates",
- });
-
- const createCippDefaults = ApiPostCall({
- urlFromData: true,
- relatedQueryKeys: "ListGDAPRoleTemplates",
- });
-
- useEffect(() => {
- if (currentTemplates.isSuccess) {
- var promptCreateDefaults = true;
- // check templates for CIPP Defaults
- if (
- currentTemplates?.data?.pages?.[0].Results?.length > 0 &&
- currentTemplates?.data?.pages?.[0].Results?.find((t) => t.TemplateId === "CIPP Defaults")
- ) {
- promptCreateDefaults = false;
- }
- setCreateDefaults(promptCreateDefaults);
- }
- }, [currentTemplates]);
- return (
-
- {createDefaults && (
- <>
-
-
- The CIPP Defaults template is missing from the GDAP Role Templates. Create it now?
-
- createCippDefaults.mutate({
- url: "/api/ExecAddGDAPRole",
- data: { TemplateId: "CIPP Defaults" },
- })
- }
- sx={{ ml: 2 }}
- startIcon={
-
-
-
- }
- >
- Create CIPP Defaults
-
-
-
-
-
-
- >
- )}
- }
- >
- Add Template
-
- }
- queryKey="ListGDAPRoleTemplates"
- maxHeightOffset="460px"
- />
-
- );
-};
-
-Page.getLayout = (page) => (
-
- {page}
-
-);
-
-export default Page;
diff --git a/src/pages/tenant/gdap-management/role-templates/index.jsx b/src/pages/tenant/gdap-management/role-templates/index.jsx
new file mode 100644
index 000000000000..adbea6e0f498
--- /dev/null
+++ b/src/pages/tenant/gdap-management/role-templates/index.jsx
@@ -0,0 +1,221 @@
+import { TabbedLayout } from "../../../../layouts/TabbedLayout";
+import { CippIcons } from "../../../../utils/icon-registry"
+import { Layout as DashboardLayout } from "../../../../layouts/index";
+import tabOptions from "../tabOptions";
+import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx";
+import { Alert, Button, Card, CardContent, SvgIcon, Typography } from "@mui/material";
+import Link from "next/link";
+import { ApiGetCallWithPagination, ApiPostCall } from "../../../../api/ApiCall";
+import { useCallback, useEffect, useState } from "react";
+import { Box, Container, Stack } from "@mui/system";
+import { CippApiResults } from "../../../../components/CippComponents/CippApiResults";
+import { CippHead } from "../../../../components/CippComponents/CippHead";
+
+const pageTitle = "GDAP Role Templates";
+const apiUrl = "/api/ExecGDAPRoleTemplate";
+
+const actions = [
+ {
+ label: "Edit Template",
+ link: "/tenant/gdap-management/role-templates/edit?templateId=[TemplateId]",
+ pinned: true,
+ icon: ,
+ },
+ {
+ label: "Clone Template",
+ url: "/api/ExecGDAPRoleTemplate?Action=Add",
+ type: "POST",
+ icon: ,
+ data: { RoleMappings: "RoleMappings" },
+ confirmText: "Enter a name for the copy of [TemplateId].",
+ fields: [
+ {
+ type: "textField",
+ name: "TemplateId",
+ label: "New template name",
+ required: true,
+ validators: {
+ validate: (value) => (value?.trim() ? true : "Enter a name for the new template"),
+ },
+ },
+ ],
+ relatedQueryKeys: ["ListGDAPRoleTemplates"],
+ },
+ {
+ label: "Create Invite",
+ link: "/tenant/gdap-management/invites/add?templateId=[TemplateId]",
+ icon: ,
+ },
+ {
+ label: "Delete Template",
+ url: "/api/ExecGDAPRoleTemplate?Action=Delete",
+ type: "POST",
+ icon: ,
+ data: { TemplateId: "TemplateId" },
+ confirmText: "Are you sure you want to delete this Role Template?",
+ },
+];
+
+const simpleColumns = ["TemplateId", "Roles", "GroupMappings"];
+
+const Page = () => {
+ const [createDefaults, setCreateDefaults] = useState(false);
+ const [hasTemplates, setHasTemplates] = useState(null);
+
+ const currentTemplates = ApiGetCallWithPagination({
+ url: apiUrl,
+ queryKey: "ListGDAPRoleTemplates",
+ });
+
+ const createCippDefaults = ApiPostCall({
+ urlFromData: true,
+ relatedQueryKeys: ["ListGDAPRoleTemplates", "ListGDAPRoles"],
+ });
+
+ useEffect(() => {
+ if (currentTemplates.isSuccess) {
+ const results = currentTemplates?.data?.pages?.[0]?.Results ?? [];
+ setHasTemplates(results.length > 0);
+ setCreateDefaults(!results.find((t) => t.TemplateId === "CIPP Defaults"));
+ }
+ }, [currentTemplates]);
+
+ // The template rows carry their mappings as objects; the table shows the role names as chips
+ // and the mapping count. Memoized - CippDataTable re-runs the map whenever this identity changes.
+ const dataMap = useCallback(
+ (row) => ({
+ ...row,
+ Roles: (row?.RoleMappings ?? []).map((mapping) => mapping?.RoleName).filter(Boolean),
+ GroupMappings: (row?.RoleMappings ?? []).length,
+ }),
+ []
+ );
+
+ if (hasTemplates === false) {
+ return (
+
+
+
+
+
+
+ No role templates yet
+
+ A role template is the set of admin roles a GDAP invite grants. Start from the
+ CIPP defaults, or pick your own roles.
+
+
+
+ createCippDefaults.mutate({
+ url: "/api/ExecAddGDAPRole",
+ data: { TemplateId: "CIPP Defaults" },
+ })
+ }
+ startIcon={
+
+
+
+ }
+ >
+ Create CIPP Defaults template
+
+ }
+ >
+ Build a custom template
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {createDefaults && (
+ <>
+
+
+ The CIPP Defaults template is missing from the GDAP Role Templates. Create it now?
+
+ createCippDefaults.mutate({
+ url: "/api/ExecAddGDAPRole",
+ data: { TemplateId: "CIPP Defaults" },
+ })
+ }
+ sx={{ ml: 2 }}
+ startIcon={
+
+
+
+ }
+ >
+ Create CIPP Defaults
+
+
+
+
+
+
+ >
+ )}
+
+ }
+ >
+ Add Template
+
+ }
+ >
+ Group Mappings
+
+ >
+ }
+ queryKey="ListGDAPRoleTemplates"
+ maxHeightOffset="460px"
+ />
+
+ );
+};
+
+Page.getLayout = (page) => (
+
+ {page}
+
+);
+
+export default Page;
diff --git a/src/pages/tenant/gdap-management/role-templates/mappings.jsx b/src/pages/tenant/gdap-management/role-templates/mappings.jsx
new file mode 100644
index 000000000000..c7505cc20d51
--- /dev/null
+++ b/src/pages/tenant/gdap-management/role-templates/mappings.jsx
@@ -0,0 +1,254 @@
+import { TabbedLayout } from "../../../../layouts/TabbedLayout";
+import { CippIcons } from "../../../../utils/icon-registry"
+import { Layout as DashboardLayout } from "../../../../layouts/index";
+import tabOptions from "../tabOptions";
+import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx";
+import { Alert, Button, Link as MuiLink, SvgIcon, Tooltip, Typography } from "@mui/material";
+import { Box, Stack } from "@mui/system";
+import Link from "next/link";
+import { useCallback, useMemo } from "react";
+import { ApiGetCall } from "../../../../api/ApiCall";
+import { CippApiDialog } from "../../../../components/CippComponents/CippApiDialog";
+import { CippPropertyList } from "../../../../components/CippComponents/CippPropertyList";
+import { useDialog } from "../../../../hooks/use-dialog";
+import { buildGdapRepairPlan } from "../../../../utils/gdap-role-options";
+
+const pageTitle = "GDAP Group Mappings";
+
+const repairQueryKeys = ["ListGDAPRoles", "ListGDAPRolesValidated", "ListGDAPRoleTemplates"];
+
+const actions = [
+ {
+ label: "Delete Mapping",
+ icon: ,
+ type: "POST",
+ url: "/api/ExecDeleteGDAPRoleMapping",
+ data: {
+ GroupId: "GroupId",
+ },
+ confirmText:
+ "Are you sure you want to delete this role mapping? Any role template that uses it loses the role. (Note: This does not delete the associated security groups or modify any GDAP relationships.)",
+ relatedQueryKeys: repairQueryKeys,
+ },
+];
+
+const simpleColumns = ["RoleName", "GroupName", "GroupStatus", "UsedInTemplates"];
+
+const offCanvas = {
+ extendedInfoFields: ["RoleName", "GroupName", "GroupStatus", "GroupStatusMessage"],
+};
+
+const Page = () => {
+ const repairDialog = useDialog();
+
+ // Validation costs a partner tenant Graph call, so it is fetched once here and folded into the
+ // table's rows rather than being asked for again by the table's own query.
+ const groupCheck = ApiGetCall({
+ url: "/api/ListGDAPRoles?validate=true",
+ queryKey: "ListGDAPRolesValidated",
+ });
+
+ const templates = ApiGetCall({
+ url: "/api/ExecGDAPRoleTemplate",
+ queryKey: "ListGDAPRoleTemplates-mappings",
+ });
+
+ const repairPlan = useMemo(() => buildGdapRepairPlan(groupCheck.data), [groupCheck.data]);
+ const hasGroupIssues = repairPlan.changes.length > 0;
+ const nothingToRepair =
+ groupCheck.isSuccess && !repairPlan.unknown && repairPlan.changes.length === 0;
+
+ const statusByGroup = useMemo(() => {
+ const lookup = {};
+ (groupCheck.data ?? []).forEach((row) => {
+ if (row?.GroupId) {
+ lookup[row.GroupId] = {
+ GroupStatus: row.GroupStatus,
+ GroupStatusMessage: row.GroupStatusMessage,
+ };
+ }
+ });
+ return lookup;
+ }, [groupCheck.data]);
+
+ // Which templates each group is used by. The API has no such view, so it is joined here.
+ const templatesByGroup = useMemo(() => {
+ const lookup = {};
+ (templates.data?.Results ?? []).forEach((template) => {
+ (template?.RoleMappings ?? []).forEach((mapping) => {
+ if (!mapping?.GroupId) return;
+ lookup[mapping.GroupId] = [...(lookup[mapping.GroupId] ?? []), template.TemplateId];
+ });
+ });
+ return lookup;
+ }, [templates.data]);
+
+ // Memoized: CippDataTable re-maps its rows whenever this identity changes.
+ const dataMap = useCallback(
+ (row) => ({
+ ...row,
+ GroupStatus: statusByGroup[row?.GroupId]?.GroupStatus ?? "Unknown",
+ GroupStatusMessage: statusByGroup[row?.GroupId]?.GroupStatusMessage ?? "",
+ UsedInTemplates: templatesByGroup[row?.GroupId] ?? [],
+ }),
+ [templatesByGroup, statusByGroup]
+ );
+
+ const repairButton = (
+ } disabled={nothingToRepair} onClick={() => repairDialog.handleOpen()}>
+ Repair mappings
+
+ );
+
+ return (
+
+
+
+
+
+ Each mapping ties a GDAP admin role to a security group in your partner tenant, and a
+ technician gains that role by being a member of the group. Mapping a group by hand is
+ an advanced option for groups that already exist and do not follow the M365 GDAP
+ naming - templates create and name groups for you.
+
+
+ {hasGroupIssues && (
+
+
+ Some mappings point at groups that no longer exist. Repair re-links or recreates the
+ M365 GDAP groups and updates every template.
+
+
+ )}
+
+
+
+ {nothingToRepair ? (
+ // A disabled MUI Button swallows pointer events, so the tooltip needs a live wrapper.
+
+ {repairButton}
+
+ ) : (
+ repairButton
+ )}
+ }>
+ Map an existing group (Advanced)
+
+ {/* The parent tab stays highlighted here, so it cannot be clicked to go back. */}
+
+
+
+ }
+ >
+ Back
+
+ >
+ }
+ queryKey="ListGDAPRoles"
+ maxHeightOffset="460px"
+ />
+
+
+ {repairPlan.unknown && (
+
+ The group check could not run for every mapping, so this list may be incomplete.
+ Repair still checks each mapping and fixes what it can.
+
+ )}
+
+
+ What will change
+
+ {repairPlan.changes.length > 0 ? (
+ ({
+ label: change.RoleName,
+ value: change.action,
+ }))}
+ />
+ ) : (
+
+ No mapping needs re-linking or recreating.
+
+ )}
+ {repairPlan.validCount > 0 && (
+
+ {repairPlan.validCount} mapping{repairPlan.validCount === 1 ? " is" : "s are"}{" "}
+ already valid and won't change.
+
+ )}
+
+
+
+ Next steps
+
+
+
+
+ Recreated groups start empty. Re-add your technicians to them before they regain
+ access.
+
+
+
+
+ Every role template is updated automatically with the corrected group ids.
+
+
+
+
+ Relationships that already had assignments against a missing group need the Reset
+ Role Mapping action on the{" "}
+
+ relationship
+
+ , or a re-run of onboarding.
+
+
+
+
+ Re-run the GDAP check on the overview to confirm the result.
+
+
+
+
+
+
+
+ );
+};
+
+Page.getLayout = (page) => (
+
+
+ {page}
+
+
+);
+
+export default Page;
diff --git a/src/pages/tenant/gdap-management/roles/add.js b/src/pages/tenant/gdap-management/roles/add.js
deleted file mode 100644
index 3cd90ac53a9e..000000000000
--- a/src/pages/tenant/gdap-management/roles/add.js
+++ /dev/null
@@ -1,328 +0,0 @@
-import React, { useState } from "react";
-import { Alert, Button, SvgIcon, Typography, Tooltip, Link } from "@mui/material";
-import CippFormPage from "../../../../components/CippFormPages/CippFormPage";
-import { Layout as DashboardLayout } from "../../../../layouts/index.js";
-import { useForm, useWatch } from "react-hook-form";
-import { CippFormComponent } from "../../../../components/CippComponents/CippFormComponent";
-import { CippFormCondition } from "../../../../components/CippComponents/CippFormCondition";
-import GDAPRoles from "../../../../data/GDAPRoles";
-import { Box, Stack, Grid } from "@mui/system";
-import { ShieldCheckIcon, PlusSmallIcon } from "@heroicons/react/24/outline";
-import { CippPropertyList } from "../../../../components/CippComponents/CippPropertyList";
-import cippDefaults from "../../../../data/CIPPDefaultGDAPRoles";
-import { ApiGetCall } from "../../../../api/ApiCall";
-import { Settings, SyncAlt } from "@mui/icons-material";
-import { CippDataTable } from "../../../../components/CippTable/CippDataTable";
-import { CippExpandableAlert } from "../../../../components/CippComponents/CippExpandableAlert";
-import { TrashIcon } from "@heroicons/react/24/outline";
-
-const Page = () => {
- const formControl = useForm({
- mode: "onChange",
- defaultValues: {
- advancedMode: false,
- },
- });
-
- const selectedGdapRoles = useWatch({
- control: formControl.control,
- name: "gdapRoles",
- });
-
- const customSuffix = useWatch({
- control: formControl.control,
- name: "customSuffix",
- });
- const [advancedMappings, setAdvancedMappings] = useState([]);
-
- const handleDefaults = () => {
- formControl.setValue("gdapRoles", cippDefaults, { shouldDirty: true });
- formControl.trigger();
- };
-
- const groupList = ApiGetCall({
- url: "/api/ExecAddGDAPRole?Action=ListGroups",
- queryKey: "ListGroups",
- });
-
- const handleAddMapping = () => {
- const selectedGroup = formControl.getValues("selectedGroup");
- const selectedRole = formControl.getValues("selectedRole");
-
- if (!selectedGroup || !selectedRole) {
- return;
- }
-
- const newMapping = {
- groupName: selectedGroup.label,
- groupId: selectedGroup.value,
- roleName: selectedRole.label,
- roleDefinitionId: selectedRole.value,
- };
-
- if (
- advancedMappings.some(
- (mapping) =>
- mapping.groupId === newMapping.groupId &&
- mapping.roleDefinitionId === newMapping.roleDefinitionId
- )
- ) {
- return;
- }
-
- setAdvancedMappings([...advancedMappings, newMapping]);
- formControl.setValue("selectedGroup", null); // Clear the selected group
- formControl.setValue("selectedRole", null); // Clear the selected role
- };
-
- const handleRemoveMapping = (mappingToRemove) => {
- const updatedMappings = advancedMappings.filter(
- (mapping) =>
- mapping.groupId !== mappingToRemove.groupId ||
- mapping.roleDefinitionId !== mappingToRemove.roleDefinitionId
- );
- setAdvancedMappings(updatedMappings);
- };
-
- return (
- <>
- {
- if (values.advancedMode) {
- return {
- Action: "AddRoleAdvanced",
- Mappings: advancedMappings,
- };
- } else {
- return values;
- }
- }}
- >
-
-
- GDAP Roles
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- For each role you select a new group will be created inside of your partner tenant
- called "M365 GDAP RoleName". Add your users to these new groups to set their GDAP
- permissions. If you need to segment your groups for different teams or to define
- custom permissions, use the Custom Suffix to create additional group mappings per
- role.
-
-
-
- Certain roles may not be compatible with GDAP. See the{" "}
-
- Microsoft Documentation
- {" "}
- on GDAP Role Guidance.
-
-
-
-
-
-
-
-
- }
- >
- Add CIPP Default Roles
-
-
-
- role.ObjectId !== "7495fdc4-34c4-4d15-a289-98788ce399fd" &&
- role.ObjectId !== "aaf43236-0c0d-4d5f-883a-6955382ac081"
- ).map((role) => ({ label: role.Name, value: role.ObjectId }))}
- multiple={true}
- creatable={false}
- required={true}
- validators={{
- validate: (value) => {
- if (!value || value.length === 0) {
- return "Please select at least one GDAP Role";
- }
- return true;
- },
- }}
- sortOptions={true}
- />
-
-
- The following groups will be created in your partner tenant if they do not already
- exist:
-
- ({
- label: `M365 GDAP ${role.label}${customSuffix ? ` - ${customSuffix}` : ""}`,
- value: GDAPRoles.find((r) => r.ObjectId === role.value).Description,
- }))}
- />
-
-
-
- The Global Administrator role is a highly privileged role that should be used with
- caution. GDAP Relationships with this role will not be eligible for auto-extend.
-
-
-
-
-
-
-
- In Advanced Mode, you can manually map existing groups to GDAP roles. This
- functionality is designed to help map existing groups to GDAP roles that do not
- match the default naming convention. Use extreme caution when mapping roles in this
- mode.
-
-
- Limitations
-
-
-
- Reserved groups and roles are unavailable for mapping, this is to prevent
- misconfigurations due to permission overlap.
-
-
- Only one role can be mapped per group. If your current configuration maps
- more than one, use the Reset Role Mapping action on the Relationship.
-
-
- Certain roles may not be compatible with GDAP. See the{" "}
-
- Microsoft Documentation
- {" "}
- on GDAP Role Guidance.
-
- )
- ) : block.formatter === "Percentage" ? (
- <>{block.data}>
- ) : block.formatter === "table" ? (
- bpaData.refetch()}
- />
- ) : (
-
- Something is wrong with your report. This field is not formatted
- correctly.
-
- )}
-
-
- ))}
- >
- )}
-
-
-
- >
- );
-};
-
-Page.getLayout = (page) => {page};
-
-export default Page;
diff --git a/src/pages/tenant/standards/bpa-report/view.jsx b/src/pages/tenant/standards/bpa-report/view.jsx
new file mode 100644
index 000000000000..3216f80a1059
--- /dev/null
+++ b/src/pages/tenant/standards/bpa-report/view.jsx
@@ -0,0 +1,241 @@
+import { Layout as DashboardLayout } from "../../../../layouts/index";
+import { Box, Container, Typography, Button, Stack, SvgIcon, Skeleton, Chip, Alert } from "@mui/material";
+import { Grid } from "@mui/system";
+import Head from "next/head";
+import { ArrowLeftIcon } from "@mui/x-date-pickers";
+import { useRouter } from "next/router";
+import { ApiGetCall } from "../../../../api/ApiCall";
+import { useSettings } from "../../../../hooks/use-settings";
+import { useEffect, useState } from "react";
+import CippButtonCard from "../../../../components/CippCards/CippButtonCard";
+import { CippDataTable } from "../../../../components/CippTable/CippDataTable";
+import { CippImageCard } from "../../../../components/CippCards/CippImageCard";
+import { get } from "lodash";
+const Page = () => {
+ const router = useRouter();
+ const { id } = router.query;
+ const [blockCards, setBlockCards] = useState([]);
+ const [layoutMode, setLayoutMode] = useState("Table");
+ const bpaTemplateList = ApiGetCall({
+ url: "/api/listBPATemplates",
+ queryKey: "ListBPATemplates-All",
+ });
+ const tenantFilter = useSettings().currentTenant;
+ const bpaData = ApiGetCall({
+ url: "/api/listBPA",
+ data: {
+ tenantFilter: tenantFilter,
+ report: id,
+ },
+ queryKey: `ListBPA-${id}-${tenantFilter}`,
+ });
+ const tenantInfo = ApiGetCall({
+ url: "/api/ListTenants",
+ queryKey: "TenantSelector",
+ });
+ const currentTenant = useSettings().currentTenant;
+
+ useEffect(() => {
+ if (bpaTemplateList.isSuccess) {
+ const bpaTemplate = bpaTemplateList.data.find(
+ (template) => template.Name === router.query.id
+ );
+ if (bpaTemplate) {
+ setLayoutMode(bpaTemplate.Style);
+ if (bpaTemplate.Style === "Tenant") {
+ const frontendFields = bpaTemplate.Data.map((block) => block.FrontendFields[0]);
+ if (bpaData.isSuccess) {
+ const tenantId = tenantInfo?.data.find(
+ (tenant) => tenant?.defaultDomainName === tenantFilter
+ )?.customerId;
+
+ const tenantData = bpaData?.data?.Data?.find((data) => data.GUID === tenantId);
+ const cards = frontendFields.map((field) => {
+ //instead of this, use lodash to get the data for blockData
+ const blockData = get(tenantData, field.value)
+ ? get(tenantData, field.value)
+ : undefined;
+ return {
+ name: field.name,
+ value: field.value,
+ desc: field.desc,
+ formatter: field.formatter,
+ data: blockData,
+ };
+ });
+ setBlockCards(cards);
+ }
+ }
+ if (bpaTemplate.Style === "Table") {
+ if (bpaData.isSuccess) {
+ //Table mode works slightly different; each Field is a datasource, but all we need is the frontEndfields and show them in a table. Field[0], Field[2]. etc all contain "FrontendFields". There can be an unlimited amount of frontendFields
+ const frontendFields = bpaTemplate.Data.map((block) => block.FrontendFields);
+ if (bpaData.isSuccess) {
+ const tenantId = tenantInfo?.data.find(
+ (tenant) => tenant?.defaultDomainName === tenantFilter
+ )?.customerId;
+
+ let tenantData =
+ currentTenant !== "AllTenants"
+ ? bpaData?.data?.Data?.find((data) => data.GUID === tenantId)
+ : bpaData?.data?.Data;
+ const flatFrontendFields = frontendFields.flat();
+ const listOfFrontEndFields = flatFrontendFields.map((subField) =>
+ //sometimes the subField contains a space. Only take the first part of the subField if it does.
+ subField?.value?.includes(" ") ? subField.value.split(" ")[0] : subField.value
+ );
+
+ tenantData = Array.isArray(tenantData) ? tenantData : [tenantData];
+ //filter down tenantData to only the fields listOfFrontEndFields
+ tenantData = tenantData.map((data) => {
+ listOfFrontEndFields.unshift("Tenant");
+ return data;
+ });
+ const cards = {
+ simpleColumns: listOfFrontEndFields,
+ formatter: "table",
+ name: "BPA Table Report",
+ data: tenantData,
+ };
+ setBlockCards([cards]);
+ }
+ }
+ }
+ }
+ }
+ }, [bpaTemplateList.isSuccess, bpaData.isSuccess, bpaData.data, currentTenant, router]);
+
+ const pageTitle = `BPA Report Viewer - ${currentTenant}`;
+ return (
+ <>
+
+ {pageTitle}
+
+
+
+
+
+ router.back()}
+ startIcon={
+
+
+
+ }
+ >
+ Back to Templates
+
+
+
+
+
+ {pageTitle}
+
+
+
+
+ {bpaTemplateList.isLoading && }
+
+
+ {currentTenant === "AllTenants" && layoutMode !== "Table" ? (
+
+
+
+ ) : (
+ <>
+ {blockCards.map((block, index) => (
+
+
+ {block.desc}
+
+ }
+ >
+ {block.data === undefined ? (
+
+ No data has been found for this item. This tenant might not be licensed
+ for this feature, or data collection failed. Please check the logs for
+ more information.
+
+ ) : block.formatter === "String" ? (
+
+ {block.data}
+
+ ) : block.formatter === "bool" ? (
+
+ ) : block.formatter === "warnBool" ? (
+
+ ) : block.formatter === "reverseBool" ? (
+
+ ) : block.formatter === "number" ? (
+ //really big number centered in the card.
+ (
+
{block.data}
+ )
+ ) : block.formatter === "Percentage" ? (
+ <>{block.data}>
+ ) : block.formatter === "table" ? (
+ bpaData.refetch()}
+ />
+ ) : (
+
+ Something is wrong with your report. This field is not formatted
+ correctly.
+
+ )}
+
+
+ ))}
+ >
+ )}
+
+
+
+ >
+ );
+};
+
+Page.getLayout = (page) => {page};
+
+export default Page;
diff --git a/src/pages/tenant/standards/domains-analyser/index.js b/src/pages/tenant/standards/domains-analyser/index.js
deleted file mode 100644
index fa586ea80b1a..000000000000
--- a/src/pages/tenant/standards/domains-analyser/index.js
+++ /dev/null
@@ -1,99 +0,0 @@
-import { Button } from '@mui/material'
-import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx'
-import { Layout as DashboardLayout } from '../../../../layouts/index.js' // had to add an extra path here because I added an extra folder structure. We should switch to absolute pathing so we dont have to deal with relative.
-import Link from 'next/link'
-import { ApiGetCall } from '../../../../api/ApiCall'
-import { useSettings } from '../../../../hooks/use-settings'
-import { CippApiResults } from '../../../../components/CippComponents/CippApiResults'
-import { CippDomainCards } from '../../../../components/CippCards/CippDomainCards'
-import { DeleteForever, TravelExplore, Refresh, Settings } from '@mui/icons-material'
-import { DomainAnalyserDialog } from '../../../../components/CippComponents/DomainAnalyserDialog'
-import { useDialog } from '../../../../hooks/use-dialog'
-
-const Page = () => {
- const currentTenant = useSettings().currentTenant
- const pageTitle = 'Domains Analyser'
- const analyserDialog = useDialog()
- const apiGetCall = ApiGetCall({
- url: '/api/ExecDomainAnalyser',
- waiting: false,
- })
- const actions = [
- {
- label: 'Add/Modify DKIM Selectors',
- type: 'POST',
- icon: ,
- url: '/api/ExecDnsConfig',
- data: { Action: '!SetDkimConfig', Domain: 'Domain' },
- confirmText: 'Enter the DKIM selectors for [Domain] (comma-separated)',
- fields: [
- {
- type: 'textField',
- name: 'Selector',
- label: 'DKIM Selectors',
- placeholder: 'selector1, selector2, selector3',
- required: true,
- },
- ],
- multiPost: false,
- },
- {
- label: 'Delete from analyser',
- type: 'POST',
- icon: ,
- url: '/api/ExecDnsConfig',
- data: { Action: '!RemoveDomain', Domain: 'Domain' },
- confirmText: 'Are you sure you want to delete this domain from the analyser?',
- multiPost: false,
- },
- ]
-
- const offCanvas = {
- children: (extendedData) => ,
- }
- return (
- <>
-
- }
- >
- Check Individual Domain
-
- }>
- Run Analysis Now
-
- >
- }
- prependComponents={}
- queryKey={`ListDomains-${currentTenant}`}
- simpleColumns={[
- 'Domain',
- 'ScorePercentage',
- 'MailProvider',
- 'SPFPassAll',
- 'MXPassTest',
- 'DMARCPresent',
- 'DMARCActionPolicy',
- 'DMARCPercentagePass',
- 'DNSSECPresent',
- 'DKIMEnabled',
- 'EnterpriseEnrollment',
- 'EnterpriseRegistration',
- ]}
- offCanvas={offCanvas}
- actions={actions}
- />
-
- >
- )
-}
-
-Page.getLayout = (page) => {page}
-
-export default Page
diff --git a/src/pages/tenant/standards/domains-analyser/index.jsx b/src/pages/tenant/standards/domains-analyser/index.jsx
new file mode 100644
index 000000000000..50453d96071e
--- /dev/null
+++ b/src/pages/tenant/standards/domains-analyser/index.jsx
@@ -0,0 +1,119 @@
+import { Button } from '@mui/material'
+import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx'
+import { Layout as DashboardLayout } from '../../../../layouts/index' // had to add an extra path here because I added an extra folder structure. We should switch to absolute pathing so we dont have to deal with relative.
+import Link from 'next/link'
+import { ApiGetCall } from '../../../../api/ApiCall'
+import { useSettings } from '../../../../hooks/use-settings'
+import { CippApiResults } from '../../../../components/CippComponents/CippApiResults'
+import { CippDomainCards } from '../../../../components/CippCards/CippDomainCards'
+import { CippIcons } from '../../../../utils/icon-registry'
+import { DomainAnalyserDialog } from '../../../../components/CippComponents/DomainAnalyserDialog'
+import { useDialog } from '../../../../hooks/use-dialog'
+
+const Page = () => {
+ const currentTenant = useSettings().currentTenant
+ const pageTitle = 'Domains Analyser'
+ const analyserDialog = useDialog()
+ const apiGetCall = ApiGetCall({
+ url: '/api/ExecDomainAnalyser',
+ waiting: false,
+ })
+ const actions = [
+ {
+ label: 'Add/Modify DKIM Selectors',
+ type: 'POST',
+ icon: ,
+ url: '/api/ExecDnsConfig',
+ data: { Action: '!SetDkimConfig', Domain: 'Domain' },
+ confirmText: 'Enter the DKIM selectors for [Domain] (comma-separated)',
+ fields: [
+ {
+ type: 'textField',
+ name: 'Selector',
+ label: 'DKIM Selectors',
+ placeholder: 'selector1, selector2, selector3',
+ required: true,
+ },
+ ],
+ multiPost: false,
+ },
+ {
+ label: 'Delete from analyser',
+ type: 'POST',
+ icon: ,
+ url: '/api/ExecDnsConfig',
+ data: { Action: '!RemoveDomain', Domain: 'Domain' },
+ confirmText: 'Are you sure you want to delete this domain from the analyser?',
+ multiPost: false,
+ },
+ ]
+
+ const offCanvas = {
+ children: (extendedData) => ,
+ }
+
+ const filters = [
+ {
+ filterName: 'Mail Provider is not Microsoft 365',
+ value: [{ id: 'MailProvider', value: 'Microsoft 365', filterFn: 'notEquals' }],
+ type: 'column',
+ },
+ {
+ filterName: 'onmicrosoft.com Domains',
+ value: [{ id: 'Domain', value: 'onmicrosoft.com' }],
+ type: 'column',
+ },
+ {
+ filterName: 'All Except onmicrosoft.com Domains',
+ value: [{ id: 'Domain', value: 'onmicrosoft.com', filterFn: 'notContains' }],
+ type: 'column',
+ },
+ ]
+
+ return (
+ <>
+
+ }
+ >
+ Check Individual Domain
+
+ }>
+ Run Analysis Now
+
+ >
+ }
+ prependComponents={}
+ queryKey={`ListDomains-${currentTenant}`}
+ filters={filters}
+ simpleColumns={[
+ 'Domain',
+ 'ScorePercentage',
+ 'MailProvider',
+ 'SPFPassAll',
+ 'MXPassTest',
+ 'DMARCPresent',
+ 'DMARCActionPolicy',
+ 'DMARCPercentagePass',
+ 'DNSSECPresent',
+ 'DKIMEnabled',
+ 'EnterpriseEnrollment',
+ 'EnterpriseRegistration',
+ ]}
+ offCanvas={offCanvas}
+ actions={actions}
+ />
+
+ >
+ )
+}
+
+Page.getLayout = (page) => {page}
+
+export default Page
diff --git a/src/pages/tenant/standards/templates/index.js b/src/pages/tenant/standards/templates/index.js
deleted file mode 100644
index 1a2779c9f4f0..000000000000
--- a/src/pages/tenant/standards/templates/index.js
+++ /dev/null
@@ -1,260 +0,0 @@
-import { Alert, Button } from '@mui/material'
-import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx'
-import { Layout as DashboardLayout } from '../../../../layouts/index.js' // had to add an extra path here because I added an extra folder structure. We should switch to absolute pathing so we dont have to deal with relative.
-import { TabbedLayout } from '../../../../layouts/TabbedLayout'
-import Link from 'next/link'
-import { CopyAll, Delete, PlayArrow, AddBox, Edit, GitHub, ContentCopy, Schedule } from '@mui/icons-material'
-import { ApiGetCall, ApiPostCall } from '../../../../api/ApiCall'
-import { Grid } from '@mui/system'
-import { CippApiResults } from '../../../../components/CippComponents/CippApiResults'
-import { EyeIcon } from '@heroicons/react/24/outline'
-import tabOptions from '../tabOptions.json'
-import { CippPolicyImportDrawer } from '../../../../components/CippComponents/CippPolicyImportDrawer.jsx'
-import { PermissionButton } from '../../../../utils/permissions.js'
-import { CippFormTemplateTenantSelector } from '../../../../components/CippComponents/CippFormTemplateTenantSelector.jsx'
-
-const Page = () => {
- const oldStandards = ApiGetCall({ url: '/api/ListStandards', queryKey: 'ListStandards-legacy' })
- const integrations = ApiGetCall({
- url: '/api/ListExtensionsConfig',
- queryKey: 'Integrations',
- refetchOnMount: false,
- refetchOnReconnect: false,
- })
-
- const pageTitle = 'Templates'
- const cardButtonPermissions = ['Tenant.Standards.ReadWrite']
- const actions = [
- {
- label: 'View Tenant Report',
- link: '/tenant/manage/applied-standards/?templateId=[GUID]',
- icon: ,
- color: 'info',
- target: '_self',
- },
- {
- label: 'Edit Template',
- //when using a link it must always be the full path /identity/administration/users/[id] for example.
- link: '/tenant/standards/templates/template?id=[GUID]&type=[type]',
- icon: ,
- color: 'success',
- target: '_self',
- },
- {
- label: 'Clone & Edit Template',
- link: '/tenant/standards/templates/template?id=[GUID]&clone=true&type=[type]',
- icon: ,
- color: 'success',
- target: '_self',
- },
- {
- label: 'Create Drift Clone',
- type: 'POST',
- url: '/api/ExecDriftClone',
- icon: ,
- color: 'warning',
- data: {
- id: 'GUID',
- },
- confirmText:
- 'Are you sure you want to create a drift clone of [templateName]? This will create a new drift template based on this template.',
- multiPost: false,
- },
- {
- label: 'Run Template Now',
- type: 'GET',
- url: '/api/ExecStandardsRun',
- icon: ,
- data: {
- TemplateId: 'GUID',
- },
- allowResubmit: true,
- customDataformatter: (row, action, formData) => ({
- TemplateId: row.GUID,
- tenantFilter: formData.tenantFilter?.value ?? formData.tenantFilter,
- }),
- children: ({ formHook, row }) => (
-
- ),
- confirmText: 'Are you sure you want to force a run of this template?',
- multiPost: false,
- },
- {
- label: 'Set Schedule',
- title: 'Set Schedule',
- type: 'POST',
- url: '/api/ExecStandardTemplateSchedule',
- icon: ,
- data: {
- TemplateId: 'GUID',
- },
- fields: [
- {
- label: 'Schedule',
- name: 'runManually',
- type: 'select',
- multiple: false,
- creatable: false,
- options: [
- { label: 'Disable schedule (run manually only)', value: 'true' },
- { label: 'Enable schedule', value: 'false' },
- ],
- required: true,
- validators: { required: { value: true, message: 'This field is required' } },
- },
- ],
- confirmText: 'Set the schedule for [templateName]?',
- condition: (row) => row.type !== 'drift',
- multiPost: false,
- },
- {
- label: 'Save to GitHub',
- type: 'POST',
- url: '/api/ExecCommunityRepo',
- icon: ,
- data: {
- Action: 'UploadTemplate',
- GUID: 'GUID',
- },
- fields: [
- {
- label: 'Repository',
- name: 'FullName',
- type: 'select',
- api: {
- url: '/api/ListCommunityRepos',
- data: {
- WriteAccess: true,
- },
- queryKey: 'CommunityRepos-Write',
- dataKey: 'Results',
- valueField: 'FullName',
- labelField: 'FullName',
- },
- multiple: false,
- creatable: false,
- required: true,
- validators: {
- required: { value: true, message: 'This field is required' },
- },
- },
- {
- label: 'Commit Message',
- placeholder: 'Enter a commit message for adding this file to GitHub',
- name: 'Message',
- type: 'textField',
- multiline: true,
- required: true,
- rows: 4,
- },
- ],
- confirmText: 'Are you sure you want to save this template to the selected repository?',
- condition: () => integrations.isSuccess && integrations?.data?.GitHub?.Enabled,
- },
- {
- label: 'Delete Template',
- type: 'POST',
- url: '/api/RemoveStandardTemplate',
- icon: ,
- data: {
- ID: 'GUID',
- },
- confirmText: 'Are you sure you want to delete [templateName]?',
- multiPost: false,
- },
- ]
- const conversionApi = ApiPostCall({ relatedQueryKeys: 'listStandardTemplates' })
- const handleConversion = () => {
- conversionApi.mutate({
- url: '/api/execStandardConvert',
- data: {},
- })
- }
- const tableFilter = (
-
- {oldStandards.isSuccess && oldStandards.data.length !== 0 && (
-
-
-
-
- You have legacy standards available. Press the button to convert these standards to
- the new format. This will create a new template for each standard you had, but will
- disable the schedule. After conversion, please check the new templates to ensure
- they are correct and re-enable the schedule.
-
-
- handleConversion()} variant={'contained'}>
- Convert Legacy Standards
-
-
-
-
-
-
-
-
- )}
-
- )
- return (
-
- }
- sx={{ mr: 1 }}
- >
- Add Template
-
- }
- sx={{ mr: 1 }}
- >
- Create Drift Template
-
-
- >
- }
- actions={actions}
- tableFilter={tableFilter}
- simpleColumns={[
- 'templateName',
- 'type',
- 'tenantFilter',
- 'excludedTenants',
- 'updatedAt',
- 'updatedBy',
- 'runManually',
- 'standards',
- ]}
- queryKey="listStandardTemplates"
- />
- )
-}
-
-Page.getLayout = (page) => (
-
- {page}
-
-)
-
-export default Page
diff --git a/src/pages/tenant/standards/templates/index.jsx b/src/pages/tenant/standards/templates/index.jsx
new file mode 100644
index 000000000000..9d56b2d79959
--- /dev/null
+++ b/src/pages/tenant/standards/templates/index.jsx
@@ -0,0 +1,261 @@
+import { Alert, Button } from '@mui/material'
+import { CippIcons } from '../../../../utils/icon-registry'
+import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx'
+import { Layout as DashboardLayout } from '../../../../layouts/index' // had to add an extra path here because I added an extra folder structure. We should switch to absolute pathing so we dont have to deal with relative.
+import { TabbedLayout } from '../../../../layouts/TabbedLayout'
+import Link from 'next/link'
+import { ApiGetCall, ApiPostCall } from '../../../../api/ApiCall'
+import { Grid } from '@mui/system'
+import { CippApiResults } from '../../../../components/CippComponents/CippApiResults'
+import tabOptions from '../tabOptions.json'
+import { CippPolicyImportDrawer } from '../../../../components/CippComponents/CippPolicyImportDrawer.jsx'
+import { PermissionButton } from '../../../../utils/permissions'
+import { CippFormTemplateTenantSelector } from '../../../../components/CippComponents/CippFormTemplateTenantSelector.jsx'
+
+const Page = () => {
+ const oldStandards = ApiGetCall({ url: '/api/ListStandards', queryKey: 'ListStandards-legacy' })
+ const integrations = ApiGetCall({
+ url: '/api/ListExtensionsConfig',
+ queryKey: 'Integrations',
+ refetchOnMount: false,
+ refetchOnReconnect: false,
+ })
+
+ const pageTitle = 'Templates'
+ const cardButtonPermissions = ['Tenant.Standards.ReadWrite']
+ const actions = [
+ {
+ label: 'View Tenant Report',
+ link: '/tenant/manage/applied-standards/?templateId=[GUID]',
+ pinned: true,
+ icon: ,
+ color: 'info',
+ target: '_self',
+ },
+ {
+ label: 'Edit Template',
+ //when using a link it must always be the full path /identity/administration/users/[id] for example.
+ link: '/tenant/standards/templates/template?id=[GUID]&type=[type]',
+ pinned: true,
+ icon: ,
+ color: 'success',
+ target: '_self',
+ },
+ {
+ label: 'Clone & Edit Template',
+ link: '/tenant/standards/templates/template?id=[GUID]&clone=true&type=[type]',
+ icon: ,
+ color: 'success',
+ target: '_self',
+ },
+ {
+ label: 'Create Drift Clone',
+ type: 'POST',
+ url: '/api/ExecDriftClone',
+ icon: ,
+ color: 'warning',
+ data: {
+ id: 'GUID',
+ },
+ confirmText:
+ 'Are you sure you want to create a drift clone of [templateName]? This will create a new drift template based on this template.',
+ multiPost: false,
+ },
+ {
+ label: 'Run Template Now',
+ type: 'GET',
+ url: '/api/ExecStandardsRun',
+ icon: ,
+ data: {
+ TemplateId: 'GUID',
+ },
+ allowResubmit: true,
+ customDataformatter: (row, action, formData) => ({
+ TemplateId: row.GUID,
+ tenantFilter: formData.tenantFilter?.value ?? formData.tenantFilter,
+ }),
+ children: ({ formHook, row }) => (
+
+ ),
+ confirmText: 'Are you sure you want to force a run of this template?',
+ multiPost: false,
+ },
+ {
+ label: 'Set Schedule',
+ title: 'Set Schedule',
+ type: 'POST',
+ url: '/api/ExecStandardTemplateSchedule',
+ icon: ,
+ data: {
+ TemplateId: 'GUID',
+ },
+ fields: [
+ {
+ label: 'Schedule',
+ name: 'runManually',
+ type: 'select',
+ multiple: false,
+ creatable: false,
+ options: [
+ { label: 'Disable schedule (run manually only)', value: 'true' },
+ { label: 'Enable schedule', value: 'false' },
+ ],
+ required: true,
+ validators: { required: { value: true, message: 'This field is required' } },
+ },
+ ],
+ confirmText: 'Set the schedule for [templateName]?',
+ condition: (row) => row.type !== 'drift',
+ multiPost: false,
+ },
+ {
+ label: 'Save to GitHub',
+ type: 'POST',
+ url: '/api/ExecCommunityRepo',
+ icon: ,
+ data: {
+ Action: 'UploadTemplate',
+ GUID: 'GUID',
+ },
+ fields: [
+ {
+ label: 'Repository',
+ name: 'FullName',
+ type: 'select',
+ api: {
+ url: '/api/ListCommunityRepos',
+ data: {
+ WriteAccess: true,
+ },
+ queryKey: 'CommunityRepos-Write',
+ dataKey: 'Results',
+ valueField: 'FullName',
+ labelField: 'FullName',
+ },
+ multiple: false,
+ creatable: false,
+ required: true,
+ validators: {
+ required: { value: true, message: 'This field is required' },
+ },
+ },
+ {
+ label: 'Commit Message',
+ placeholder: 'Enter a commit message for adding this file to GitHub',
+ name: 'Message',
+ type: 'textField',
+ multiline: true,
+ required: true,
+ rows: 4,
+ },
+ ],
+ confirmText: 'Are you sure you want to save this template to the selected repository?',
+ condition: () => integrations.isSuccess && integrations?.data?.GitHub?.Enabled,
+ },
+ {
+ label: 'Delete Template',
+ type: 'POST',
+ url: '/api/RemoveStandardTemplate',
+ icon: ,
+ data: {
+ ID: 'GUID',
+ },
+ confirmText: 'Are you sure you want to delete [templateName]?',
+ multiPost: false,
+ },
+ ]
+ const conversionApi = ApiPostCall({ relatedQueryKeys: 'listStandardTemplates' })
+ const handleConversion = () => {
+ conversionApi.mutate({
+ url: '/api/execStandardConvert',
+ data: {},
+ })
+ }
+ const tableFilter = (
+
+ {oldStandards.isSuccess && oldStandards.data.length !== 0 && (
+
+
+
+
+ You have legacy standards available. Press the button to convert these standards to
+ the new format. This will create a new template for each standard you had, but will
+ disable the schedule. After conversion, please check the new templates to ensure
+ they are correct and re-enable the schedule.
+
+
+ handleConversion()} variant={'contained'}>
+ Convert Legacy Standards
+
+
+
+
+
+
+
+
+ )}
+
+ )
+ return (
+
+ }
+ sx={{ mr: 1 }}
+ >
+ Add Template
+
+ }
+ sx={{ mr: 1 }}
+ >
+ Create Drift Template
+
+
+ >
+ }
+ actions={actions}
+ tableFilter={tableFilter}
+ simpleColumns={[
+ 'templateName',
+ 'type',
+ 'tenantFilter',
+ 'excludedTenants',
+ 'updatedAt',
+ 'updatedBy',
+ 'runManually',
+ 'standards',
+ ]}
+ queryKey="listStandardTemplates"
+ />
+ )
+}
+
+Page.getLayout = (page) => (
+
+ {page}
+
+)
+
+export default Page
diff --git a/src/pages/tenant/standards/templates/template.jsx b/src/pages/tenant/standards/templates/template.jsx
index 95c1398f260a..1a42d5f53a9b 100644
--- a/src/pages/tenant/standards/templates/template.jsx
+++ b/src/pages/tenant/standards/templates/template.jsx
@@ -1,9 +1,9 @@
import { Box, Button, Container, Stack, Typography, SvgIcon, Skeleton } from '@mui/material'
+import { CippIcons } from '../../../../utils/icon-registry'
import { Grid } from '@mui/system'
-import { Layout as DashboardLayout } from '../../../../layouts/index.js'
+import { Layout as DashboardLayout } from '../../../../layouts/index'
import { useForm, useWatch } from 'react-hook-form'
import { useRouter } from 'next/router'
-import { Add, SaveRounded } from '@mui/icons-material'
import { useEffect, useState, useCallback, useMemo, useRef, lazy, Suspense } from 'react'
import standards from '../../../../data/standards'
import CippStandardAccordion from '../../../../components/CippStandards/CippStandardAccordion'
@@ -16,7 +16,7 @@ import { ArrowLeftIcon } from '@mui/x-date-pickers'
import { useDialog } from '../../../../hooks/use-dialog'
import { ApiGetCall } from '../../../../api/ApiCall'
import { get } from 'lodash'
-import { createDriftManagementActions } from '../../manage/driftManagementActions'
+import { createDriftManagementActions } from '../../../../components/CippComponents/CippDriftManagementActions'
import { ActionsMenu } from '../../../../components/actions-menu'
import { useSettings } from '../../../../hooks/use-settings'
import { CippHead } from '../../../../components/CippComponents/CippHead'
@@ -368,11 +368,12 @@ const Page = () => {
+ sx={{
+ justifyContent: "space-between",
+ alignItems: { xs: 'stretch', sm: 'center' },
+ mb: 3
+ }}>
{editMode
? isDriftMode
@@ -392,7 +393,7 @@ const Page = () => {
variant="contained"
color="primary"
onClick={handleSave}
- startIcon={}
+ startIcon={}
disabled={isSaveDisabled}
>
Save Template
@@ -401,7 +402,7 @@ const Page = () => {
variant="outlined"
color="primary"
onClick={handleOpenDialog}
- startIcon={}
+ startIcon={}
>
Add Standard to Template
@@ -479,7 +480,7 @@ const Page = () => {
)}
- )
+ );
}
Page.getLayout = (page) => {page}
diff --git a/src/pages/tenant/tools/appapproval/index.js b/src/pages/tenant/tools/appapproval/index.js
deleted file mode 100644
index b05b6938415d..000000000000
--- a/src/pages/tenant/tools/appapproval/index.js
+++ /dev/null
@@ -1,51 +0,0 @@
-import { Layout as DashboardLayout } from "../../../../layouts/index.js";
-import { CippWizardConfirmation } from "../../../../components/CippWizard/CippWizardConfirmation";
-import CippWizardPage from "../../../../components/CippWizard/CippWizardPage.jsx";
-import { CippTenantStep } from "../../../../components/CippWizard/CippTenantStep.jsx";
-import { CippWizardAppApproval } from "../../../../components/CippWizard/CippWizardAppApproval";
-import { Alert } from "@mui/material";
-
-const Page = () => {
- const steps = [
- {
- title: "Step 1",
- description: "Tenant Selection",
- component: CippTenantStep,
- componentProps: {
- preText: (
-
- Did you know you can also deploy applications by using our standards? Use the standard
- if you have to deploy an application to all your tenants
-
- ),
- allTenants: false,
- type: "multiple",
- },
- },
- {
- title: "Step 2",
- description: "App Selection",
- component: CippWizardAppApproval,
- },
- {
- title: "Step 3",
- description: "Confirmation",
- component: CippWizardConfirmation,
- },
- ];
-
- return (
- <>
-
- >
- );
-};
-
-Page.getLayout = (page) => {page};
-
-export default Page;
diff --git a/src/pages/tenant/tools/appapproval/index.jsx b/src/pages/tenant/tools/appapproval/index.jsx
new file mode 100644
index 000000000000..028fcca2338c
--- /dev/null
+++ b/src/pages/tenant/tools/appapproval/index.jsx
@@ -0,0 +1,51 @@
+import { Layout as DashboardLayout } from "../../../../layouts/index";
+import { CippWizardConfirmation } from "../../../../components/CippWizard/CippWizardConfirmation";
+import CippWizardPage from "../../../../components/CippWizard/CippWizardPage.jsx";
+import { CippTenantStep } from "../../../../components/CippWizard/CippTenantStep.jsx";
+import { CippWizardAppApproval } from "../../../../components/CippWizard/CippWizardAppApproval";
+import { Alert } from "@mui/material";
+
+const Page = () => {
+ const steps = [
+ {
+ title: "Step 1",
+ description: "Tenant Selection",
+ component: CippTenantStep,
+ componentProps: {
+ preText: (
+
+ Did you know you can also deploy applications by using our standards? Use the standard
+ if you have to deploy an application to all your tenants
+
+ ),
+ allTenants: false,
+ type: "multiple",
+ },
+ },
+ {
+ title: "Step 2",
+ description: "App Selection",
+ component: CippWizardAppApproval,
+ },
+ {
+ title: "Step 3",
+ description: "Confirmation",
+ component: CippWizardConfirmation,
+ },
+ ];
+
+ return (
+ <>
+
+ >
+ );
+};
+
+Page.getLayout = (page) => {page};
+
+export default Page;
diff --git a/src/pages/tenant/tools/bpa-report-builder/index.js b/src/pages/tenant/tools/bpa-report-builder/index.js
deleted file mode 100644
index 337f594354ee..000000000000
--- a/src/pages/tenant/tools/bpa-report-builder/index.js
+++ /dev/null
@@ -1,17 +0,0 @@
-
-import { Layout as DashboardLayout } from "../../../../layouts/index.js";
-
-const Page = () => {
- const pageTitle = "BPA Report Builder";
-
- return (
-
-
{pageTitle}
-
This is a placeholder page for the bpa report builder section.