{createProfile.isLoading
- ? "Creating..."
+ ? 'Creating...'
: createProfile.isSuccess
- ? "Create Another"
- : "Create Profile"}
+ ? 'Create Another'
+ : 'Create Profile'}
Close
@@ -137,7 +169,7 @@ export const CippAutopilotProfileDrawer = ({
type="multiple"
allTenants={true}
preselectedEnabled={true}
- validators={{ required: "At least one tenant must be selected" }}
+ validators={{ required: 'At least one tenant must be selected' }}
/>
@@ -153,10 +185,13 @@ export const CippAutopilotProfileDrawer = ({
name="DisplayName"
formControl={formControl}
validators={{
- required: "Display Name is required",
+ required: 'Display Name is required',
validate: (value) =>
- (value ?? "").trim().length > 0 || "Display Name is required",
- pattern: { value: PROFILE_NAME_PATTERN, message: PROFILE_NAME_MESSAGE },
+ (value ?? '').trim().length > 0 || 'Display Name is required',
+ pattern: {
+ value: PROFILE_NAME_PATTERN,
+ message: PROFILE_NAME_MESSAGE,
+ },
}}
required={true}
helperText={PROFILE_NAME_HINT}
@@ -169,12 +204,14 @@ export const CippAutopilotProfileDrawer = ({
label="Language"
name="languages"
options={[
- { value: "os-default", label: "Operating system default" },
- { value: "user-select", label: "User Select" },
- ...languageList.map(({ language, tag, "Geographic area": geographicArea }) => ({
- value: tag,
- label: `${language} - ${geographicArea}`, // Format as "language - geographic area" for display
- })),
+ { value: 'os-default', label: 'Operating system default' },
+ { value: 'user-select', label: 'User Select' },
+ ...languageList.map(
+ ({ language, tag, 'Geographic area': geographicArea }) => ({
+ value: tag,
+ label: `${language} - ${geographicArea}`, // Format as "language - geographic area" for display
+ })
+ ),
]}
formControl={formControl}
multiple={false}
@@ -215,6 +252,43 @@ export const CippAutopilotProfileDrawer = ({
name="Assignto"
formControl={formControl}
/>
+ {assignToGroups === false && (
+
+ {!canReadGroups ? (
+
+ Assigning this profile to groups requires the Identity Group
+ Read permission. You can still create it without an
+ assignment.
+
+ ) : singleTenant ? (
+
+ option?.groupType
+ ? `${option.displayName} (${option.groupType})`
+ : (option?.displayName ?? ''),
+ valueField: 'id',
+ queryKey: 'ListGroups',
+ showRefresh: true,
+ }}
+ />
+ ) : (
+
+ Selected groups are tenant-specific, so profiling by group
+ requires selecting a single tenant. Leave this unchecked to
+ create the profile without an assignment.
+
+ )}
+
+ )}
@@ -269,5 +343,5 @@ export const CippAutopilotProfileDrawer = ({
>
- );
-};
+ )
+}
diff --git a/src/components/CippComponents/CippBottomSheet.jsx b/src/components/CippComponents/CippBottomSheet.jsx
new file mode 100644
index 000000000000..a1bb308472c6
--- /dev/null
+++ b/src/components/CippComponents/CippBottomSheet.jsx
@@ -0,0 +1,83 @@
+import { Box, SwipeableDrawer, Typography } from "@mui/material";
+import { useSwipeCloseTransition } from "../../hooks/use-swipe-close-transition";
+
+// SwipeableDrawer requires onOpen; these sheets are only ever opened programmatically.
+const noop = () => {};
+
+// Mobile bottom sheet — the house rule for the mobile surface is that anything rendered
+// as a Menu on desktop becomes one of these: predictable position, 44px+ rows, thumb reach.
+export const CippBottomSheet = (props) => {
+ const { open, onClose, title, children, footer, onExited, SlideProps, ModalProps, ...other } =
+ props;
+ const swipeClose = useSwipeCloseTransition(open, onClose);
+ return (
+ theme.zIndex.modal + 1 }}
+ PaperProps={{
+ sx: {
+ borderTopLeftRadius: 14,
+ borderTopRightRadius: 14,
+ maxHeight: "85dvh",
+ display: "flex",
+ flexDirection: "column",
+ },
+ }}
+ {...other}
+ >
+
+ {title && (
+
+ {title}
+
+ )}
+
+ {children}
+
+ {footer && (
+
+ {footer}
+
+ )}
+
+ );
+};
diff --git a/src/components/CippComponents/CippBreadcrumbNav.jsx b/src/components/CippComponents/CippBreadcrumbNav.jsx
index 2e42c4a904f8..05b197398a54 100644
--- a/src/components/CippComponents/CippBreadcrumbNav.jsx
+++ b/src/components/CippComponents/CippBreadcrumbNav.jsx
@@ -1,10 +1,11 @@
import { useEffect, useState, useRef } from 'react'
import { useRouter } from 'next/router'
-import { Breadcrumbs, Link, Typography, Box, IconButton, Tooltip } from '@mui/material'
+import { Breadcrumbs, Divider, Link, Typography, Box, IconButton, Tooltip, useMediaQuery } from '@mui/material'
import { History, AccountTree } from '@mui/icons-material'
import { nativeMenuItems } from '../../layouts/config'
import { useSettings } from '../../hooks/use-settings'
import { CippBookmarkStar } from './CippBookmarkStar'
+import { useIsMobileLayout } from '../../hooks/use-breakpoint'
const MAX_HISTORY_STORAGE = 20 // Maximum number of pages to keep in history
const MAX_BREADCRUMB_DISPLAY = 5 // Maximum number of breadcrumbs to display at once
@@ -36,9 +37,13 @@ const loadTabOptions = () => {
})
}
-export const CippBreadcrumbNav = () => {
+export const CippBreadcrumbNav = ({ withRail = false } = {}) => {
const router = useRouter()
const settings = useSettings()
+ // Phones get one line: leading crumbs collapse behind MUI's ellipsis button instead of
+ // the trail wrapping to two rows of chrome above every table.
+ const mdDown = useMediaQuery((theme) => theme.breakpoints.down('md'))
+ const isMobileLayout = useIsMobileLayout()
const [history, setHistory] = useState([])
const [mode, setMode] = useState(settings.breadcrumbMode || 'hierarchical')
const [tabOptions] = useState(loadTabOptions)
@@ -605,6 +610,19 @@ export const CippBreadcrumbNav = () => {
const bookmarkCategory = trail.length > 1 ? crumbTitle(trail[0]) : ''
const bookmarkStar =
+ // The layout's rail chrome (gutter box + divider) travels with the nav so that when the
+ // nav renders nothing — error routes, or a single crumb on a phone — no stray hairline is
+ // left where the rail was. The AllTenants interstitial renders the nav bare (no withRail).
+ const rail = (node) =>
+ withRail ? (
+ <>
+ {node}
+
+ >
+ ) : (
+ node
+ )
+
// Render based on mode
if (mode === 'hierarchical') {
const breadcrumbs = trail
@@ -614,7 +632,18 @@ export const CippBreadcrumbNav = () => {
return null
}
- return (
+ // On phones the rail stands down (taking the mode toggle and bookmark star with it) when
+ // it has nothing the page doesn't already say: a single crumb is no hierarchy, and the
+ // dashboard's whole trail ("Overview > Identity") is just its own tab set — the exact
+ // list the view picker beneath it presents. Desktop keeps the rail everywhere.
+ const isHomeSurface = breadcrumbs.every(
+ (crumb) => crumb.path === '/' || crumb.path?.startsWith('/dashboardv2')
+ )
+ if (isMobileLayout && (breadcrumbs.length < 2 || isHomeSurface)) {
+ return null
+ }
+
+ return rail(
{
{
minWidth: 0,
userSelect: 'text',
'& .MuiBreadcrumbs-separator': { userSelect: 'text' },
+ ...(mdDown && {
+ '& .MuiBreadcrumbs-ol': { flexWrap: 'nowrap' },
+ '& .MuiBreadcrumbs-li': { minWidth: 0 },
+ '& .MuiBreadcrumbs-li > *': {
+ whiteSpace: 'nowrap',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ display: 'block',
+ },
+ }),
}}
>
{breadcrumbs.map((crumb, index) => {
@@ -703,7 +745,9 @@ export const CippBreadcrumbNav = () => {
}
})}
- {bookmarkStar}
+ {/* Mobile: star pinned to the right edge — a stable tap target instead of trailing
+ the crumb text. Desktop keeps it directly after the last crumb. */}
+ {bookmarkStar}
)
}
@@ -717,7 +761,7 @@ export const CippBreadcrumbNav = () => {
// Show only the last MAX_BREADCRUMB_DISPLAY items
const visibleHistory = history.slice(-MAX_BREADCRUMB_DISPLAY)
- return (
+ return rail(
{
{
minWidth: 0,
userSelect: 'text',
'& .MuiBreadcrumbs-separator': { userSelect: 'text' },
+ ...(mdDown && {
+ '& .MuiBreadcrumbs-ol': { flexWrap: 'nowrap' },
+ '& .MuiBreadcrumbs-li': { minWidth: 0 },
+ }),
}}
>
{visibleHistory.map((page, index) => {
@@ -786,7 +836,7 @@ export const CippBreadcrumbNav = () => {
)
})}
- {bookmarkStar}
+ {bookmarkStar}
)
}
diff --git a/src/components/CippComponents/CippCAPolicyBuilder.jsx b/src/components/CippComponents/CippCAPolicyBuilder.jsx
index 39e4bc968144..ddbc9b4de2a6 100644
--- a/src/components/CippComponents/CippCAPolicyBuilder.jsx
+++ b/src/components/CippComponents/CippCAPolicyBuilder.jsx
@@ -108,6 +108,114 @@ function SectionHeader({ title, description, requiresLicense, icon }) {
);
}
+/**
+ * The guest / external user block, which Graph models identically on the include and the
+ * exclude side. Rendered twice from UsersSection rather than duplicated.
+ */
+function GuestsOrExternalUsersFields({ formControl, disabled, prefix, direction, typeOptions }) {
+ const base = `${prefix}.${direction}GuestsOrExternalUsers`;
+ const Verb = direction === "include" ? "Include" : "Exclude";
+ const scopeHelp =
+ direction === "include"
+ ? "Choose whether the policy applies to all external tenants or specific ones. Only relevant for external user types (not internal guests)."
+ : "Choose whether the exclusion applies to all external tenants or specific ones. Only relevant for external user types (not internal guests).";
+
+ // Entra rejects an include-guests assignment (error 1119) when Include Users also carries one of
+ // its special values. The exclude side has no such constraint, so only watch on the include side.
+ const includeUsers = useWatch({ control: formControl.control, name: `${prefix}.includeUsers` });
+ const guestTypes = useWatch({ control: formControl.control, name: `${base}.guestOrExternalUserTypes` });
+ const conflictsWithIncludeUsers = useMemo(() => {
+ if (direction !== "include") return false;
+ const hasGuestTypes = Array.isArray(guestTypes) ? guestTypes.length > 0 : Boolean(guestTypes);
+ if (!hasGuestTypes) return false;
+ const users = Array.isArray(includeUsers) ? includeUsers : [includeUsers];
+ return users.some((u) => ["All", "None", "GuestsOrExternalUsers"].includes(u?.value ?? u));
+ }, [direction, guestTypes, includeUsers]);
+
+ return (
+ <>
+
+
+
+ {Verb} Guests or External Users
+
+
+
+
+
+
+ Select one or more external user types to {direction} {direction === "include" ? "in" : "from"} this
+ policy.
+
+ {conflictsWithIncludeUsers && (
+
+ Entra ID rejects this combination. Clear "Include Users" — an include-guests
+ assignment cannot be combined with All, None or GuestsOrExternalUsers.
+
+ )}
+
+
+
+
+
+ {scopeHelp}
+
+
+
+
+
+
+ Enter the tenant IDs to scope this to (e.g. your partner tenant ID for a service
+ provider {direction === "include" ? "inclusion" : "exclusion"}).
+
+
+
+
+ >
+ );
+}
+
// ---------------------------------------------------------------------------
// Users & Groups section
// ---------------------------------------------------------------------------
@@ -215,79 +323,21 @@ function UsersSection({ formControl, disabled, prefix = "conditions.users" }) {
/>
- {/* Guest / External User Exclusions */}
-
-
-
- Exclude Guests or External Users
-
-
-
-
-
-
- Select one or more external user types to exclude from this policy.
-
-
-
-
-
-
- Choose whether the exclusion applies to all external tenants or specific ones. Only
- relevant for external user types (not internal guests).
-
-
-
-
-
-
- Enter the tenant IDs to scope this exclusion to (e.g. your partner tenant ID for
- service provider exclusion).
-
-
-
-
+ disabled={disabled}
+ prefix={prefix}
+ direction="include"
+ typeOptions={guestTypeOpts}
+ />
+
);
}
@@ -305,6 +355,11 @@ function ApplicationsSection({ formControl, disabled, prefix = "conditions.appli
() => enumToOptions(schemaDef?.properties?.includeUserActions),
[schemaDef]
);
+ const filterSchema = resolveRef("#/$defs/conditionalAccessFilter");
+ const filterModeOpts = useMemo(
+ () => enumToOptions(filterSchema?.properties?.mode),
+ [filterSchema]
+ );
return (
@@ -344,6 +399,52 @@ function ApplicationsSection({ formControl, disabled, prefix = "conditions.appli
options={userActionOpts}
/>
+
+
+
+ Used instead of cloud apps. In a template, deployment matches these by display name and
+ creates the authentication context in the tenant if it is missing.
+
+
+
+ {/* Application filter */}
+
+
+
+ Application Filter
+
+
+
+
+
+
+
+
+
);
}
@@ -399,6 +500,17 @@ function ConditionsSection({ formControl, disabled }) {
[locationSchema]
);
+ const clientAppsSchema = resolveRef("#/$defs/conditionalAccessClientApplications");
+ const includeSpOpts = useMemo(
+ () => specialValueOptions(clientAppsSchema?.properties?.includeServicePrincipals),
+ [clientAppsSchema]
+ );
+ const filterSchema = resolveRef("#/$defs/conditionalAccessFilter");
+ const filterModeOpts = useMemo(
+ () => enumToOptions(filterSchema?.properties?.mode),
+ [filterSchema]
+ );
+
return (
{/* Client app types */}
@@ -570,6 +682,67 @@ function ConditionsSection({ formControl, disabled }) {
options={authFlowOpts}
/>
+
+ {/* Workload identities */}
+
+
+
+
+ Workload Identities
+
+
+
+
+
+
+
+
+ Scopes the policy to workload identities instead of users. Leave empty for a user policy.
+
+
+
+
+
+
+
+
+
+
+
);
}
@@ -625,7 +798,10 @@ function GrantControlsSection({ formControl, disabled }) {
? gc.builtInControls.length
: gc.builtInControls) ||
gc.authenticationStrength?.id ||
- (Array.isArray(gc.termsOfUse) ? gc.termsOfUse.length : gc.termsOfUse);
+ (Array.isArray(gc.termsOfUse) ? gc.termsOfUse.length : gc.termsOfUse) ||
+ (Array.isArray(gc.customAuthenticationFactors)
+ ? gc.customAuthenticationFactors.length
+ : gc.customAuthenticationFactors);
if (hasControls && !(value?.value ?? value)) {
return "Grant operator is required when grant controls are set";
}
@@ -676,6 +852,21 @@ function GrantControlsSection({ formControl, disabled }) {
placeholder="Terms of use agreement IDs"
/>
+
+
+
+ Legacy custom controls from an external identity provider, referenced by ID.
+
+
);
}
@@ -1356,6 +1547,9 @@ export default CippCAPolicyBuilder;
* Call this in your form's submit handler to strip out { label, value }
* wrapper objects from autoComplete fields, remove empty/null branches,
* and ensure the JSON is ready to send to AddCAPolicy / AddCATemplate.
+ *
+ * Absent keys are fine: the backend canonicalizer (Format-CIPPCAPolicy) restores every managed
+ * key it needs as its cleared form at deploy/edit time, so this stays a plain payload cleanup.
*/
export function extractCAPolicyJSON(formValues) {
const clean = (obj) => {
@@ -1449,7 +1643,8 @@ export function extractCAPolicyJSON(formValues) {
}
// Post-process: strip session control sub-objects where isEnabled is false.
- // Graph validates fields like `mode` even when disabled — safest to omit entirely.
+ // Graph validates fields like `mode` even when disabled — safest to omit entirely; the backend
+ // canonicalizer turns the resulting absence into the null that clears it on the policy.
if (cleaned.sessionControls) {
const sessionKeys = [
"applicationEnforcedRestrictions",
diff --git a/src/components/CippComponents/CippDateRangeFilter.jsx b/src/components/CippComponents/CippDateRangeFilter.jsx
index e9123f568dbd..cb5f4e0a2531 100644
--- a/src/components/CippComponents/CippDateRangeFilter.jsx
+++ b/src/components/CippComponents/CippDateRangeFilter.jsx
@@ -68,7 +68,7 @@ export const CippDateRangeFilter = ({
{formControl.watch("dateFilter") === "relative" && (
-
+
-
+
-
+
-
+
- }>
-
+ }
+ // summary is a centered ButtonBase, an unshrinkable row spills both edges
+ sx={{ "& .MuiAccordionSummary-content": { minWidth: 0 } }}
+ >
+
{title}
@@ -210,7 +220,12 @@ const ResourceAccordion = ({ title, resourceId, chipLabel, children, riskSummary
/>
)}
-
+
{chipLabel != null && }
diff --git a/src/components/CippComponents/CippEnterpriseAppSwitcher.jsx b/src/components/CippComponents/CippEnterpriseAppSwitcher.jsx
new file mode 100644
index 000000000000..99f4ec6f0ee3
--- /dev/null
+++ b/src/components/CippComponents/CippEnterpriseAppSwitcher.jsx
@@ -0,0 +1,28 @@
+import { CippEntitySwitcher } from "./CippEntitySwitcher";
+
+/**
+ * The enterprise app pages' title-as-switcher: CippEntitySwitcher preset over the tenant's
+ * service principals, swapping spId (the SP object ID, matching the table links) so the
+ * current tab (Overview, API permissions) is preserved.
+ */
+export const CippEnterpriseAppSwitcher = ({ title, currentSpId, tenantFilter }) => (
+ app.appId}
+ />
+);
diff --git a/src/components/CippComponents/CippEntitySwitcher.jsx b/src/components/CippComponents/CippEntitySwitcher.jsx
new file mode 100644
index 000000000000..215e7563da04
--- /dev/null
+++ b/src/components/CippComponents/CippEntitySwitcher.jsx
@@ -0,0 +1,221 @@
+import { useMemo, useRef, useState } from "react";
+import { useRouter } from "next/router";
+import {
+ Box,
+ ButtonBase,
+ InputAdornment,
+ List,
+ ListItemButton,
+ ListItemText,
+ Popover,
+ Skeleton,
+ TextField,
+ Typography,
+} from "@mui/material";
+import { visuallyHidden } from "@mui/utils";
+import { Check, KeyboardArrowDown, Search } from "@mui/icons-material";
+import { ApiGetCall } from "../../api/ApiCall";
+import { CippBottomSheet } from "./CippBottomSheet";
+import { useIsMobileLayout } from "../../hooks/use-breakpoint";
+
+/**
+ * A detail page's title as a switcher: the entity's name in heading clothes with a chevron,
+ * opening a searchable list of sibling entities to jump straight to another one without going
+ * back through the table. Selection swaps only `queryParamKey` in the current route, so
+ * whatever tab you are on stays the tab you land on. Mount via HeaderedTabbedLayout's
+ * titleControl slot; per-entity presets (CippUserSwitcher and friends) wrap this.
+ *
+ * Same trigger both breakpoints; the list rides in a Popover on desktop and the house
+ * bottom sheet on phones. The list loads when first opened, not with the page — pass
+ * `eager` only when the query is already cached app-wide (e.g. the tenant selector's).
+ */
+export const CippEntitySwitcher = ({
+ title,
+ currentId,
+ queryParamKey,
+ api,
+ entityName,
+ entityNamePlural = `${entityName}s`,
+ getOptions = (data) => data?.Results ?? [],
+ getId = (row) => row.id,
+ getPrimary = (row) => row.displayName,
+ getSecondary,
+ // For endpoints without server-side ordering (Intune, ListGDAPRelationships).
+ sortByPrimary = false,
+ eager = false,
+}) => {
+ const router = useRouter();
+ const isMobile = useIsMobileLayout();
+ const [open, setOpen] = useState(false);
+ const [search, setSearch] = useState("");
+ const anchorRef = useRef(null);
+
+ const listRequest = ApiGetCall({
+ ...api,
+ waiting: open || eager,
+ });
+
+ const filtered = useMemo(() => {
+ let rows = getOptions(listRequest.data) ?? [];
+ if (sortByPrimary) {
+ rows = [...rows].sort((a, b) =>
+ String(getPrimary(a) ?? "").localeCompare(String(getPrimary(b) ?? ""), undefined, {
+ sensitivity: "base",
+ })
+ );
+ }
+ const needle = search.trim().toLowerCase();
+ if (!needle) return rows;
+ return rows.filter(
+ (row) =>
+ String(getPrimary(row) ?? "").toLowerCase().includes(needle) ||
+ String(getSecondary?.(row) ?? "").toLowerCase().includes(needle)
+ );
+ }, [listRequest.data, search, sortByPrimary, getOptions, getId, getPrimary, getSecondary]);
+
+ const handleClose = () => {
+ setOpen(false);
+ setSearch("");
+ };
+
+ const handleSelect = (row) => {
+ handleClose();
+ if (getId(row) === currentId) return;
+ router.push({
+ pathname: router.pathname,
+ query: { ...router.query, [queryParamKey]: getId(row) },
+ });
+ };
+
+ const sheetTitle = entityNamePlural.charAt(0).toUpperCase() + entityNamePlural.slice(1);
+
+ const listBody = (
+ <>
+
+ setSearch(event.target.value)}
+ InputProps={{
+ startAdornment: (
+
+
+
+ ),
+ }}
+ />
+
+ {/* Dense two-line rows in the tenant selector's clothes — the first cut used the
+ default List metrics and read as a page of loosely scattered names. */}
+
+ {listRequest.isFetching &&
+ [...Array(6)].map((_, index) => (
+
+
+
+
+ ))}
+ {!listRequest.isFetching && filtered.length === 0 && (
+
+ No {entityNamePlural} match.
+
+ )}
+ {!listRequest.isFetching &&
+ filtered.map((row) => (
+ handleSelect(row)}
+ sx={{ minHeight: 44, py: 0.5, px: 2, gap: 1 }}
+ >
+
+ {getId(row) === currentId && (
+
+ )}
+
+ ))}
+
+ >
+ );
+
+ return (
+ <>
+ setOpen(true)}
+ aria-haspopup="dialog"
+ sx={{
+ minWidth: 0,
+ maxWidth: "100%",
+ display: "flex",
+ alignItems: "center",
+ gap: 0.75,
+ borderRadius: 1,
+ textAlign: "left",
+ justifyContent: "flex-start",
+ }}
+ >
+ {/* Same wrap rule as the layout's plain title: truncate on mobile, wrap on desktop.
+ When the title wraps, its box fills the row, so a sibling chevron ends up
+ stranded at the far edge — on desktop the chevron rides inline after the last
+ word instead. Mobile keeps the sibling: inline would be clipped by noWrap. */}
+ {isMobile ? (
+ <>
+
+ {title}
+
+ {/* Extends the accessible name instead of replacing it, so voice control can
+ still activate the trigger by the visible name (same rule as CippTabPicker). */}
+
+ switch {entityName}
+
+
+ >
+ ) : (
+ <>
+
+ {title}
+
+
+ {/* Sibling of the heading, not inside it: inline nodes concatenate without a
+ space in the accessible name, gluing the title to "switch". */}
+
+ switch {entityName}
+
+ >
+ )}
+
+ {isMobile ? (
+
+ {listBody}
+
+ ) : (
+
+ {listBody}
+
+ )}
+ >
+ );
+};
diff --git a/src/components/CippComponents/CippExpandableAlert.jsx b/src/components/CippComponents/CippExpandableAlert.jsx
new file mode 100644
index 000000000000..b48afb0bbd72
--- /dev/null
+++ b/src/components/CippComponents/CippExpandableAlert.jsx
@@ -0,0 +1,60 @@
+import { useEffect, useRef, useState } from "react";
+import { Alert, Box, Link } from "@mui/material";
+import { useIsMobileLayout } from "../../hooks/use-breakpoint";
+
+/**
+ * An Alert that earns its screen space on a phone: below the mobile breakpoint the message
+ * clamps to a few lines with a Show more toggle, instead of pushing the page's actual
+ * content under the fold (the CIPP Roles intro alert filled most of the first screen).
+ * Desktop always shows the full message — the width absorbs it.
+ *
+ * Whether the toggle appears is measured, not assumed: a message short enough to fit its
+ * clamp renders exactly like a plain Alert.
+ */
+export const CippExpandableAlert = ({ children, collapsedLines = 3, ...alertProps }) => {
+ const isMobile = useIsMobileLayout();
+ const [expanded, setExpanded] = useState(false);
+ const [clipped, setClipped] = useState(false);
+ const messageRef = useRef(null);
+
+ useEffect(() => {
+ // Measure only while clamped: expanding removes the overflow, and remeasuring then
+ // would drop the Show less control with no way back.
+ if (!isMobile || expanded) return;
+ const el = messageRef.current;
+ if (el) setClipped(el.scrollHeight > el.clientHeight + 1);
+ }, [isMobile, expanded, children]);
+
+ const clamped = isMobile && !expanded;
+
+ return (
+
+
+ {children}
+
+ {isMobile && clipped && (
+ setExpanded((prev) => !prev)}
+ sx={{ mt: 0.5, fontWeight: 600 }}
+ >
+ {expanded ? "Show less" : "Show more"}
+
+ )}
+
+ );
+};
diff --git a/src/components/CippComponents/CippGdapRelationshipSwitcher.jsx b/src/components/CippComponents/CippGdapRelationshipSwitcher.jsx
new file mode 100644
index 000000000000..312db6eae721
--- /dev/null
+++ b/src/components/CippComponents/CippGdapRelationshipSwitcher.jsx
@@ -0,0 +1,22 @@
+import { CippEntitySwitcher } from "./CippEntitySwitcher";
+
+/**
+ * The GDAP relationship pages' title-as-switcher: CippEntitySwitcher preset over all
+ * relationships (partner-level, no tenantFilter), swapping id so the current tab
+ * (Details, Role Mappings) is preserved while reviewing relationship after relationship.
+ */
+export const CippGdapRelationshipSwitcher = ({ title, currentRelationshipId }) => (
+ relationship.customer?.displayName ?? "No Customer Set"}
+ getSecondary={(relationship) => relationship.displayName}
+ sortByPrimary
+ />
+);
diff --git a/src/components/CippComponents/CippImpersonationBanner.jsx b/src/components/CippComponents/CippImpersonationBanner.jsx
new file mode 100644
index 000000000000..44ec89ddd234
--- /dev/null
+++ b/src/components/CippComponents/CippImpersonationBanner.jsx
@@ -0,0 +1,103 @@
+import { useEffect, useRef } from 'react'
+import { Box, Button, Stack, Typography } from '@mui/material'
+import { alpha, useTheme } from '@mui/material/styles'
+import { Logout, TheaterComedy } from '@mui/icons-material'
+import { useQueryClient } from '@tanstack/react-query'
+import {
+ exitImpersonation,
+ getImpersonatedRole,
+ subscribeImpersonation,
+} from '../../utils/impersonation'
+import { useSyncExternalStore } from 'react'
+
+/**
+ * Full-width impersonation notice, rendered above the top nav (same slot and height
+ * contract as CippMaintenanceBanner: publishes --cipp-banner-h so the fixed chrome
+ * offsets itself). Source of truth is the localStorage store, NOT /api/me - the banner
+ * and its Exit button must work even when the impersonated role can't load /me.
+ * Known limitation shared with the maintenance banner: --cipp-banner-h is a single
+ * global slot, so if both banners show at once the last writer wins.
+ */
+export const CippImpersonationBanner = () => {
+ const theme = useTheme()
+ const rootRef = useRef(null)
+ const queryClient = useQueryClient()
+
+ const role = useSyncExternalStore(subscribeImpersonation, getImpersonatedRole, () => null)
+ const visible = Boolean(role)
+
+ useEffect(() => {
+ const root = document.documentElement
+ const clear = () => root.style.setProperty('--cipp-banner-h', '0px')
+
+ if (!visible || !rootRef.current) {
+ clear()
+ return undefined
+ }
+
+ const element = rootRef.current
+ const publish = () => root.style.setProperty('--cipp-banner-h', `${element.offsetHeight}px`)
+ publish()
+
+ if (typeof ResizeObserver === 'undefined') return clear
+
+ const observer = new ResizeObserver(publish)
+ observer.observe(element)
+ return () => {
+ observer.disconnect()
+ clear()
+ }
+ }, [visible])
+
+ if (!visible) return null
+
+ // Tinted like CippMaintenanceBanner's non-solid style: warning tint over an opaque
+ // surface with an accent bar, so text keeps normal contrast in both themes instead
+ // of white-on-orange.
+ const palette = theme.palette.warning
+ const isDark = theme.palette.mode === 'dark'
+ const tint = alpha(palette.main, isDark ? 0.16 : 0.12)
+ const foreground = palette[isDark ? 'light' : 'dark']
+
+ return (
+
+
+
+
+ Impersonating {role} — you are seeing CIPP as this role sees it. API
+ access is enforced under this role until you exit.
+
+ }
+ onClick={() => exitImpersonation(queryClient)}
+ sx={{ whiteSpace: 'nowrap', flexShrink: 0 }}
+ >
+ Exit impersonation
+
+
+
+ )
+}
diff --git a/src/components/CippComponents/CippIntuneDeviceActions.jsx b/src/components/CippComponents/CippIntuneDeviceActions.jsx
index fca1cfe8cee7..8f56cbb7f3c8 100644
--- a/src/components/CippComponents/CippIntuneDeviceActions.jsx
+++ b/src/components/CippComponents/CippIntuneDeviceActions.jsx
@@ -15,6 +15,7 @@ import {
Recycling,
ManageAccounts,
GroupAdd,
+ RemoveModerator,
} from '@mui/icons-material'
// Shared between the MEM devices list page and the View Device detail page.
@@ -304,6 +305,19 @@ export const getIntuneDeviceActions = ({ tenantFilter } = {}) => [
confirmText:
'Are you sure you want to update the Windows Defender signatures for [deviceName]?',
},
+ {
+ label: 'Offboard from Defender for Endpoint',
+ type: 'POST',
+ icon:
,
+ url: '/api/ExecDeviceAction',
+ data: {
+ GUID: 'azureADDeviceId',
+ Action: 'offboardMDEDevice',
+ },
+ condition: (row) => row.operatingSystem === 'Windows',
+ confirmText:
+ 'Are you sure you want to offboard [deviceName] from Microsoft Defender for Endpoint? This queues an offboarding action via the MDE API and cannot be undone without re-onboarding the device.',
+ },
// This endpoint currently does not work, Graph just returns an error. Leaving this here for now in case it is fixed in the future. -Zac
// {
// label: 'Generate logs and ship to MEM',
@@ -351,7 +365,7 @@ export const getIntuneDeviceActions = ({ tenantFilter } = {}) => [
url: '/api/ExecDeviceAction',
data: {
GUID: 'id',
- Action: 'cleanWindowsDevice',
+ Action: 'wipe',
keepUserData: false,
keepEnrollmentData: true,
},
@@ -365,7 +379,7 @@ export const getIntuneDeviceActions = ({ tenantFilter } = {}) => [
url: '/api/ExecDeviceAction',
data: {
GUID: 'id',
- Action: 'cleanWindowsDevice',
+ Action: 'wipe',
keepUserData: false,
keepEnrollmentData: false,
},
@@ -379,7 +393,7 @@ export const getIntuneDeviceActions = ({ tenantFilter } = {}) => [
url: '/api/ExecDeviceAction',
data: {
GUID: 'id',
- Action: 'cleanWindowsDevice',
+ Action: 'wipe',
keepEnrollmentData: true,
keepUserData: false,
useProtectedWipe: true,
@@ -395,7 +409,7 @@ export const getIntuneDeviceActions = ({ tenantFilter } = {}) => [
url: '/api/ExecDeviceAction',
data: {
GUID: 'id',
- Action: 'cleanWindowsDevice',
+ Action: 'wipe',
keepEnrollmentData: false,
keepUserData: false,
useProtectedWipe: true,
@@ -404,6 +418,26 @@ export const getIntuneDeviceActions = ({ tenantFilter } = {}) => [
confirmText:
'Are you sure you want to wipe [deviceName]? This will also remove enrollment data. Continuing at powerloss may cause boot issues if wipe is interrupted.',
},
+ {
+ label: 'Wipe Device',
+ type: 'POST',
+ icon:
,
+ url: '/api/ExecDeviceAction',
+ data: {
+ GUID: 'id',
+ Action: 'wipe',
+ },
+ fields: [
+ {
+ type: 'textField',
+ name: 'macOsUnlockCode',
+ label: 'Recovery PIN (optional, 6 digits)',
+ },
+ ],
+ condition: (row) => row.operatingSystem === 'macOS',
+ confirmText:
+ 'Are you sure you want to wipe [deviceName]? This erases all content and settings and cannot be undone. Intel Macs without a T2 security chip require the recovery PIN to unlock the device after the wipe.',
+ },
{
label: 'Autopilot Reset',
type: 'POST',
@@ -412,8 +446,8 @@ export const getIntuneDeviceActions = ({ tenantFilter } = {}) => [
data: {
GUID: 'id',
Action: 'wipe',
- keepUserData: 'false',
- keepEnrollmentData: 'true',
+ keepUserData: false,
+ keepEnrollmentData: true,
},
condition: (row) => row.operatingSystem === 'Windows',
confirmText: 'Are you sure you want to Autopilot Reset [deviceName]?',
diff --git a/src/components/CippComponents/CippIntuneSettingsEditor.jsx b/src/components/CippComponents/CippIntuneSettingsEditor.jsx
index 4712a557d31d..b346b89d08b4 100644
--- a/src/components/CippComponents/CippIntuneSettingsEditor.jsx
+++ b/src/components/CippComponents/CippIntuneSettingsEditor.jsx
@@ -142,7 +142,7 @@ const LeafDetails = ({ leaf, fieldPrefix, formControl, variableOptions }) => {
return (
-
+
{
))}
{!usesVariable && rawValues.length > 0 && (
- Optional Settings
-
+
-
+
{
export const CippMaintenanceBanner = ({ alert }) => {
const theme = useTheme()
const rootRef = useRef(null)
+ const messageRef = useRef(null)
+ // On phones a long notice pushes the whole chrome down by its height — clamp the message
+ // to two lines with a Read more toggle there. Desktop keeps the full inline message.
+ const mdDown = useMediaQuery(theme.breakpoints.down('md'))
+ const [messageExpanded, setMessageExpanded] = useState(false)
+ const [messageClamped, setMessageClamped] = useState(false)
+ const clampActive = mdDown && !messageExpanded
+
+ // Measured off a frame rather than synchronously in the effect: the clamped height isn't
+ // final until the browser has laid the text out, and a synchronous setState here would
+ // cascade a second render on every pass.
+ useEffect(() => {
+ const element = messageRef.current
+ if (!mdDown || !element) {
+ const frame = requestAnimationFrame(() => setMessageClamped(false))
+ return () => cancelAnimationFrame(frame)
+ }
+
+ const measure = () =>
+ setMessageClamped(
+ // Expanded text no longer overflows — keep the toggle so it can collapse again.
+ messageExpanded || element.scrollHeight > element.clientHeight + 1
+ )
+
+ const frame = requestAnimationFrame(measure)
+ if (typeof ResizeObserver === 'undefined') {
+ return () => cancelAnimationFrame(frame)
+ }
+ const observer = new ResizeObserver(measure)
+ observer.observe(element)
+ return () => {
+ cancelAnimationFrame(frame)
+ observer.disconnect()
+ }
+ }, [mdDown, messageExpanded, alert?.Alert])
const noticeId = alert?.noticeId
const dismissible = alert?.dismissible !== false
@@ -193,7 +228,7 @@ export const CippMaintenanceBanner = ({ alert }) => {
- {
)}
-
+
{alert.Alert}
+ {messageClamped && (
+ setMessageExpanded((prev) => !prev)}
+ sx={{ color: 'inherit', fontWeight: 600, textDecorationColor: 'currentColor' }}
+ >
+ {messageExpanded ? 'Show less' : 'Read more'}
+
+ )}
{windowText && (
{
color: 'text.primary',
opacity: solid ? 0.85 : 0.62,
fontVariantNumeric: 'tabular-nums',
- whiteSpace: 'nowrap',
+ whiteSpace: { xs: 'normal', md: 'nowrap' },
}}
>
{windowText}
diff --git a/src/components/CippComponents/CippMap.jsx b/src/components/CippComponents/CippMap.jsx
index 5efed559ef70..6cfa53366afc 100644
--- a/src/components/CippComponents/CippMap.jsx
+++ b/src/components/CippComponents/CippMap.jsx
@@ -16,7 +16,9 @@ L.Icon.Default.mergeOptions({
export default function CippMap({
markers = [],
zoom = 11,
- mapSx = { height: "400px", width: "600px" },
+ // maxWidth instead of a fixed width: a hard 600px canvas scrolled the page sideways in any
+ // narrower cell (the View User sign-in map renders in an xs: 12 grid item on a phone).
+ mapSx = { height: "400px", width: "100%", maxWidth: "600px" },
...props
}) {
const mapRef = useRef();
diff --git a/src/components/CippComponents/CippMessageDeliveryInfo.jsx b/src/components/CippComponents/CippMessageDeliveryInfo.jsx
index 032e2178f549..08e71602b8cf 100644
--- a/src/components/CippComponents/CippMessageDeliveryInfo.jsx
+++ b/src/components/CippComponents/CippMessageDeliveryInfo.jsx
@@ -155,7 +155,7 @@ export const CippMessageDeliveryInfo = ({ emailSource }) => {
/>
{authEntries.length > 0 && (
-
+
{authEntries.map(([label, result]) => (
{
{darkMode ? : }
- {messageHtml}
+ {/* Sanitized but untrusted layout: marketing mail ships fixed
+
s, so the message scrolls inside its own
+ card instead of widening the page body. */}
+
+ {messageHtml}
+
diff --git a/src/components/CippComponents/CippMobileTenantPicker.jsx b/src/components/CippComponents/CippMobileTenantPicker.jsx
new file mode 100644
index 000000000000..706d93b63287
--- /dev/null
+++ b/src/components/CippComponents/CippMobileTenantPicker.jsx
@@ -0,0 +1,282 @@
+import { useMemo, useState } from "react";
+import {
+ Avatar,
+ Box,
+ ButtonBase,
+ Chip,
+ Dialog,
+ IconButton,
+ InputAdornment,
+ List,
+ ListItemButton,
+ ListItemText,
+ ListSubheader,
+ OutlinedInput,
+ Typography,
+} from "@mui/material";
+import { Close, KeyboardArrowDown, Public, Search, Star, StarBorder } from "@mui/icons-material";
+import { useRouter } from "next/router";
+import { useQueryClient } from "@tanstack/react-query";
+import { ApiGetCall } from "../../api/ApiCall";
+import { useSettings } from "../../hooks/use-settings";
+import { useTenantPreferences } from "../../hooks/use-tenant-preferences";
+
+// Mobile replacement for the 400px CippTenantSelector Autocomplete: a top-bar chip opening
+// a fullscreen picker (the CippApiDialog fullscreen-on-mobile precedent). Shares the
+// "TenantSelector" query cache and the same favourites/recent preference store. Selection
+// writes settings + the tenantFilter URL param directly — the desktop selector (which
+// normally owns that sync) is not mounted on mobile.
+export const CippMobileTenantPicker = () => {
+ const [open, setOpen] = useState(false);
+ const [search, setSearch] = useState("");
+ const router = useRouter();
+ const settings = useSettings();
+ const queryClient = useQueryClient();
+ const { recent, favorites, trackRecent, toggleFavorite, isFavorite } = useTenantPreferences();
+
+ const tenantList = ApiGetCall({
+ url: "/api/listTenants",
+ data: { AllTenantSelector: true },
+ queryKey: "TenantSelector",
+ refetchOnMount: false,
+ refetchOnReconnect: false,
+ keepPreviousData: true,
+ });
+
+ const currentTenant = router.query.tenantFilter ?? settings.currentTenant;
+
+ const tenants = useMemo(
+ () => (tenantList.isSuccess && Array.isArray(tenantList.data) ? tenantList.data : []),
+ [tenantList.isSuccess, tenantList.data]
+ );
+
+ const currentDisplayName = useMemo(() => {
+ if (currentTenant === "AllTenants") return "All Tenants";
+ const match = tenants.find((t) => t.defaultDomainName === currentTenant);
+ return match?.displayName ?? currentTenant ?? "Select tenant";
+ }, [tenants, currentTenant]);
+
+ const groups = useMemo(() => {
+ const selectable = tenants.filter((t) => t.defaultDomainName !== "AllTenants");
+ const query = search.trim().toLowerCase();
+ const matches = query
+ ? selectable.filter(
+ (t) =>
+ t.displayName?.toLowerCase().includes(query) ||
+ t.defaultDomainName?.toLowerCase().includes(query)
+ )
+ : selectable;
+
+ const favoriteValues = new Set(favorites.map((f) => f.value));
+ const recentValues = recent.map((r) => r.value).filter((v) => !favoriteValues.has(v));
+ const recentSet = new Set(recentValues);
+ const byValue = new Map(matches.map((t) => [t.defaultDomainName, t]));
+
+ return {
+ favorites: favorites.map((f) => byValue.get(f.value)).filter(Boolean),
+ recent: recentValues.map((v) => byValue.get(v)).filter(Boolean),
+ all: matches
+ .filter((t) => !favoriteValues.has(t.defaultDomainName) && !recentSet.has(t.defaultDomainName))
+ .slice()
+ .sort((a, b) => (a.displayName ?? "").localeCompare(b.displayName ?? "")),
+ };
+ }, [tenants, favorites, recent, search]);
+
+ const selectTenant = (value, tenant) => {
+ // Same contract as the desktop selector's URL watcher: cancel in-flight queries,
+ // update settings, and normalize the tenantFilter URL param.
+ queryClient.cancelQueries();
+ if (tenant) {
+ trackRecent({
+ value: tenant.defaultDomainName,
+ label: `${tenant.displayName} (${tenant.defaultDomainName})`,
+ addedFields: {
+ defaultDomainName: tenant.defaultDomainName,
+ displayName: tenant.displayName,
+ customerId: tenant.customerId,
+ initialDomainName: tenant.initialDomainName,
+ },
+ });
+ }
+ settings.handleUpdate({ currentTenant: value });
+ router.replace(
+ {
+ pathname: router.pathname,
+ query: { ...router.query, tenantFilter: value },
+ },
+ undefined,
+ { shallow: true }
+ );
+ setOpen(false);
+ setSearch("");
+ };
+
+ const renderTenantRow = (tenant) => {
+ const value = tenant.defaultDomainName;
+ const favorited = isFavorite(value);
+ const isCurrent = value === currentTenant;
+ return (
+ selectTenant(value, tenant)}
+ sx={{ minHeight: 52, gap: 1.5 }}
+ >
+
+ {(tenant.displayName ?? "?").charAt(0).toUpperCase()}
+
+
+ {isCurrent && (
+
+ )}
+ {
+ event.stopPropagation();
+ toggleFavorite({
+ value,
+ label: `${tenant.displayName} (${value})`,
+ });
+ }}
+ sx={{
+ color: favorited ? "warning.main" : "action.active",
+ flexShrink: 0,
+ minWidth: 44,
+ minHeight: 44,
+ }}
+ >
+ {favorited ? : }
+
+
+ );
+ };
+
+ return (
+ <>
+ setOpen(true)}
+ aria-label="Select tenant"
+ sx={{
+ flex: 1,
+ minWidth: 0,
+ height: 40,
+ px: 1.25,
+ borderRadius: 1,
+ display: "flex",
+ alignItems: "center",
+ gap: 0.75,
+ justifyContent: "flex-start",
+ bgcolor: "rgba(255,255,255,.08)",
+ color: "common.white",
+ }}
+ >
+ {currentTenant === "AllTenants" && }
+
+ {currentDisplayName}
+
+ {/* Pinned to the chip's right edge so it reads as the control's affordance rather
+ than punctuation trailing whatever the tenant happens to be called */}
+
+
+
+ setOpen(false)}>
+
+ setOpen(false)} aria-label="Close" sx={{ minWidth: 44, minHeight: 44 }}>
+
+
+ Select tenant
+
+
+ setSearch(event.target.value)}
+ inputProps={{ enterKeyHint: "search", "aria-label": "Search tenants" }}
+ startAdornment={
+
+
+
+ }
+ sx={{ minHeight: 44 }}
+ />
+
+
+
+ {!search && (
+ selectTenant("AllTenants")}
+ sx={{ minHeight: 52, gap: 1.5 }}
+ >
+ {/* Avatar's default colour is background.default, so setting only bgcolor
+ left the glyph a dark grey sitting on the accent. getContrastText rather
+ than contrastText: the accent is a mid orange, and white on it measures
+ 2.6:1 — below the 3:1 a 24px glyph needs. This picks the dark ink. */}
+ theme.palette.getContrastText(theme.palette.primary.main),
+ }}
+ >
+
+
+
+ {currentTenant === "AllTenants" && (
+
+ )}
+
+ )}
+ {groups.favorites.length > 0 && (
+ <>
+ Favorites
+ {groups.favorites.map(renderTenantRow)}
+ >
+ )}
+ {groups.recent.length > 0 && (
+ <>
+ Recent
+ {groups.recent.map(renderTenantRow)}
+ >
+ )}
+ All tenants
+ {tenantList.isFetching && groups.all.length === 0 && (
+
+ Loading tenants…
+
+ )}
+ {groups.all.map(renderTenantRow)}
+ {!tenantList.isFetching &&
+ search &&
+ groups.all.length + groups.favorites.length + groups.recent.length === 0 && (
+
+ No tenants match “{search}”.
+
+ )}
+
+
+
+ >
+ );
+};
diff --git a/src/components/CippComponents/CippMultiQueueTracker.jsx b/src/components/CippComponents/CippMultiQueueTracker.jsx
index 4be060760ed3..d1c29443905b 100644
--- a/src/components/CippComponents/CippMultiQueueTracker.jsx
+++ b/src/components/CippComponents/CippMultiQueueTracker.jsx
@@ -48,7 +48,10 @@ export const CippMultiQueueTracker = ({ queueIds = [], relatedQueryKeys = [], la
data: { QueueIds: idKey },
queryKey: `CippQueues-${idKey || 'none'}`,
waiting: ids.length > 0,
- refetchInterval: (data) => (isFinished(data?.Summary?.Status) ? false : 3000),
+ // TanStack Query v5 hands this callback the Query object, not the data. Reading the data
+ // off query.state is what makes the interval actually return false on completion - with
+ // the v4 (data) signature the status is never found and the poll runs forever.
+ refetchInterval: (query) => (isFinished(query?.state?.data?.Summary?.Status) ? false : 3000),
refetchOnWindowFocus: false,
staleTime: 0,
})
diff --git a/src/components/CippComponents/CippOffCanvas.jsx b/src/components/CippComponents/CippOffCanvas.jsx
index abbe5aa682a4..e37e593edfdd 100644
--- a/src/components/CippComponents/CippOffCanvas.jsx
+++ b/src/components/CippComponents/CippOffCanvas.jsx
@@ -1,11 +1,14 @@
-import { Drawer, Box, IconButton, Typography, Divider } from "@mui/material";
+import { Drawer, Box, Button, IconButton, Typography, Divider } from "@mui/material";
import { CippPropertyListCard } from "../CippCards/CippPropertyListCard";
import { getCippTranslation } from "../../utils/get-cipp-translation";
import { getCippFormatting } from "../../utils/get-cipp-formatting";
import { useMediaQuery, Grid } from "@mui/system";
import CloseIcon from "@mui/icons-material/Close";
+import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
+import { renderUrlValue } from "../../utils/render-url-value";
+import { useHistoryDismiss } from "../../hooks/use-history-dismiss";
export const CippOffCanvas = (props) => {
const {
@@ -23,18 +26,32 @@ export const CippOffCanvas = (props) => {
onNavigateDown,
canNavigateUp = false,
canNavigateDown = false,
+ navigationPosition,
contentPadding = 2,
keepMounted = false,
+ actionsPosition = "top",
+ richFormatting = false,
+ aboveModal = false,
} = props;
const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md"));
+ // Pages that hand-pick extendedInfoFields expect the flat text rendering. richFormatting
+ // asks for the same nodes the table cells use — copy chips, links, status icons — which
+ // is what the card view's generated fallback needs, since its fields ARE table columns.
+ const formatField = (value, field, isArray) => {
+ if (!richFormatting) {
+ return getCippFormatting(value, field, isArray ? "array" : "text", "both");
+ }
+ return renderUrlValue(value, field) ?? getCippFormatting(value, field, undefined, "both");
+ };
+
const extendedInfo = extendedInfoFields.map((field) => {
const value = field.split(".").reduce((acc, part) => acc && acc[part], extendedData);
if (value === undefined || value === null) {
if (extendedData?.[field] !== undefined && extendedData?.[field] !== null) {
return {
label: getCippTranslation(field),
- value: getCippFormatting(extendedData[field], field, "text", "both"),
+ value: formatField(extendedData[field], field, false),
};
} else {
return {
@@ -45,35 +62,40 @@ export const CippOffCanvas = (props) => {
} else if (Array.isArray(value)) {
return {
label: getCippTranslation(field),
- value: getCippFormatting(value, field, "array", "both"),
+ value: formatField(value, field, true),
};
} else {
return {
label: getCippTranslation(field),
- value: getCippFormatting(value, field, "text", "both"),
+ value: formatField(value, field, false),
};
}
});
- if (mdDown) {
- drawerWidth = "100%";
- } else {
- var drawerWidth = 400;
- switch (size) {
- case "sm":
- drawerWidth = 400;
- break;
- case "md":
- drawerWidth = 600;
- break;
- case "lg":
- drawerWidth = 800;
- break;
- case "xl":
- drawerWidth = 1000;
- break;
- }
- }
+ const infoCard = (extendedInfo.length > 0 || actions?.length > 0) && (
+
+
+
+ );
+
+ const SIZE_WIDTHS = { sm: 400, md: 600, lg: 800, xl: 1000 };
+ const drawerWidth = mdDown ? "100%" : (SIZE_WIDTHS[size] ?? 400);
+ // Prev/next navigation exists on this drawer (row detail view); on phones the 24px
+ // header arrows move to a 44px bottom bar in thumb reach.
+ const hasRowNavigation = canNavigateUp || canNavigateDown;
+ const showBottomNav = mdDown && hasRowNavigation;
+
+ // Below md this drawer reads as a detail page, so the back gesture has to behave like the
+ // header's back chevron. Without a history entry of its own, swiping back from a row's
+ // details leaves the list page entirely — and takes the table's loaded state with it.
+ useHistoryDismiss(visible, onClose, mdDown);
return (
<>
@@ -84,6 +106,9 @@ export const CippOffCanvas = (props) => {
ModalProps={{
keepMounted: keepMounted,
}}
+ // A stock Drawer sits at 1200 and a Dialog at 1300, so a drawer opened from inside a
+ // dialog renders behind it. Same lift CippBottomSheet takes, for the same reason.
+ sx={aboveModal ? { zIndex: (theme) => theme.zIndex.modal + 1 } : undefined}
anchor={"right"}
open={visible}
onClose={onClose}
@@ -91,9 +116,21 @@ export const CippOffCanvas = (props) => {
- {title}
+ {/* Phone convention: back chevron on the left — the drawer reads as a detail page */}
+ {mdDown ? (
+
+
+
+
+
+ {title}
+
+
+ ) : (
+ {title}
+ )}
- {(canNavigateUp || canNavigateDown) && (
+ {hasRowNavigation && !mdDown && (
<>
{
>
)}
-
-
-
+ {!mdDown && (
+
+
+
+ )}
@@ -137,18 +176,7 @@ export const CippOffCanvas = (props) => {
}}
>
- {extendedInfo.length > 0 && (
-
-
-
- )}
+ {actionsPosition !== "bottom" && infoCard}
{
{typeof children === "function" ? children(extendedData) : children}
+ {actionsPosition === "bottom" && infoCard}
@@ -183,6 +212,49 @@ export const CippOffCanvas = (props) => {
{footer}
)}
+
+ {/* Mobile prev/next bar — 44px targets in thumb reach */}
+ {showBottomNav && (
+
+ }
+ onClick={onNavigateUp}
+ disabled={!canNavigateUp}
+ sx={{ flex: 1, minHeight: 44, borderColor: "divider" }}
+ >
+ Prev
+
+ {navigationPosition?.total > 0 && (
+
+ {navigationPosition.index} of {navigationPosition.total}
+
+ )}
+ }
+ onClick={onNavigateDown}
+ disabled={!canNavigateDown}
+ sx={{ flex: 1, minHeight: 44, borderColor: "divider" }}
+ >
+ Next
+
+
+ )}
>
diff --git a/src/components/CippComponents/CippOffboardingDefaultSettings.jsx b/src/components/CippComponents/CippOffboardingDefaultSettings.jsx
index 34fefd8ab509..fc481042f6a6 100644
--- a/src/components/CippComponents/CippOffboardingDefaultSettings.jsx
+++ b/src/components/CippComponents/CippOffboardingDefaultSettings.jsx
@@ -222,7 +222,21 @@ export const CippOffboardingDefaultSettings = (props) => {
]}
cardButton={
-
+
+ Out of Office Message
+
+
+ Leave blank to not set. CIPP %variable% tokens (for example %tenantname%) are resolved
+ when the offboarding job runs. %username% is not the offboarded user.
+
+
+
Send results to
diff --git a/src/components/CippComponents/CippPageActionsFab.jsx b/src/components/CippComponents/CippPageActionsFab.jsx
new file mode 100644
index 000000000000..a9553776dadd
--- /dev/null
+++ b/src/components/CippComponents/CippPageActionsFab.jsx
@@ -0,0 +1,161 @@
+import { useState } from 'react'
+import { useSheetHandoff } from '../../hooks/use-sheet-handoff'
+import {
+ Divider,
+ Fab,
+ List,
+ ListItemButton,
+ ListItemIcon,
+ ListItemText,
+ ListSubheader,
+ Stack,
+} from '@mui/material'
+import { MoreHoriz } from '@mui/icons-material'
+import { CippBottomSheet } from './CippBottomSheet'
+import {
+ useActionCornerClaim,
+ useTabNavigation,
+} from '../../layouts/tab-navigation-context'
+
+// The mobile page-actions pattern: one FAB in the bottom-right corner opening a bottom
+// sheet of actions. CippSpeedDial cedes this corner below md, so the FAB is the only
+// fixed control there. With restackButtons (default), children laid out for a desktop
+// CardHeader are restacked vertically at full width; purpose-built sheet content (list
+// rows) should pass restackButtons={false}.
+//
+// Actions only — a tabbed layout's destinations live in CippTabPicker, in the content
+// flow. This FAB does claim the corner so a headered layout hands its page actions here
+// rather than adding a second FAB of its own.
+export const CippPageActionsFab = (props) => {
+ const {
+ title,
+ // One glyph for every page-actions FAB. A "+" only ever told the truth on pages whose
+ // sheet creates things — on a report page the single action is a sync. MoreVert is the
+ // row kebab, so the FAB takes the horizontal variant.
+ icon = ,
+ ariaLabel = 'Page actions',
+ restackButtons = true,
+ sheetProps,
+ // The tabbed layout's own fallback FAB must not claim the corner it is filling —
+ // claiming would flip isActionCornerClaimed, unmount it, release, and loop.
+ claimActionCorner = true,
+ children,
+ } = props
+
+ const [open, setOpen] = useState(false)
+ const sheet = useSheetHandoff(() => setOpen(false))
+ const tabNav = useTabNavigation()
+ // A tabbed layout may own page-level actions too (HeaderedTabbedLayout's ActionsMenu);
+ // they belong in this sheet rather than in a cramped header menu.
+ const layoutActions = (tabNav?.enabled && tabNav.actions) || []
+ useActionCornerClaim(claimActionCorner)
+
+ // With both kinds of content the sections label themselves, so a sheet title would only
+ // repeat one of them; a single-purpose sheet takes the heading instead of a subheader.
+ const sectioned = Boolean(children) && layoutActions.length > 0
+ const resolvedTitle = title ?? (sectioned ? undefined : 'Actions')
+
+ return (
+ <>
+ setOpen(true)}
+ sx={{
+ position: 'fixed',
+ right: 16,
+ bottom: 'calc(env(safe-area-inset-bottom) + 20px)',
+ zIndex: (theme) => theme.zIndex.speedDial,
+ }}
+ >
+ {icon}
+
+
+ * ': { width: '100%' },
+ // A cardButton is as often a Stack as a Box (autopilot's three import
+ // buttons are a `direction="row"` Stack). Matching only Box left those in a
+ // row while the rule below stretched each button to 100% — three full-width
+ // buttons side by side, running off the sheet.
+ '& .MuiBox-root, & .MuiStack-root': {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'stretch',
+ gap: 1,
+ },
+ // Stack's `spacing` compiles to margin-left between children, which survives
+ // the flip to a column and would indent every row after the first.
+ '& .MuiStack-root > *': { marginLeft: 0, marginTop: 0 },
+ '& .MuiButton-root': {
+ width: '100%',
+ justifyContent: 'flex-start',
+ minHeight: 44,
+ },
+ // Text buttons default to the primary accent, which on the sheet's paper
+ // reads as orange-on-grey and doesn't match the ListItemButton rows below
+ // them. Contained and outlined buttons keep their colour — those are
+ // deliberate calls to action, not list rows.
+ '& .MuiButton-text': { color: 'text.primary' },
+ }),
+ }}
+ onClick={(event) => {
+ // A tap on any action has done its job — close the sheet so the drawer/dialog
+ // it opened isn't stacked under it (the sheet sits at modal + 1, so it would be
+ // ON TOP). menuitem covers MenuItem children; role=button covers ListItemButton,
+ // which renders as a div.
+ if (event.target?.closest?.("button, a, [role='menuitem'], [role='button']")) {
+ setOpen(false)
+ }
+ }}
+ >
+ {children}
+
+ {layoutActions.length > 0 && (
+ <>
+ {sectioned ? : null}
+
+ Actions
+
+ ) : null
+ }
+ >
+ {layoutActions.map((action, index) => (
+ sheet.run(action.onClick)}
+ >
+ {action.icon && (
+
+ {action.icon}
+
+ )}
+
+
+ ))}
+
+ >
+ )}
+
+ >
+ )
+}
diff --git a/src/components/CippComponents/CippPermissionSetDrawer.jsx b/src/components/CippComponents/CippPermissionSetDrawer.jsx
index cd432409a144..8d4488575219 100644
--- a/src/components/CippComponents/CippPermissionSetDrawer.jsx
+++ b/src/components/CippComponents/CippPermissionSetDrawer.jsx
@@ -148,7 +148,9 @@ export const CippPermissionSetDrawer = ({
onClose={handleDrawerClose}
size="xl"
>
-
+ {/* The drawer already pays contentPadding on a phone; 24px more on top of it, plus
+ each card's own gutters, leaves the form reading through a third of the screen. */}
+
{isEditMode
diff --git a/src/components/CippComponents/CippPropertyList.jsx b/src/components/CippComponents/CippPropertyList.jsx
index e6b5fed8f1d0..c3fd8b2bd688 100644
--- a/src/components/CippComponents/CippPropertyList.jsx
+++ b/src/components/CippComponents/CippPropertyList.jsx
@@ -20,7 +20,7 @@ export const CippPropertyList = (props) => {
return item?.label === "" || item?.label === undefined || item?.label === null;
};
- const setPadding = isLabelPresent ? { py: 0.5, px: 3 } : { py: 1.5, px: 3 };
+ const setPadding = isLabelPresent ? { py: 0.5, px: { xs: 2, md: 3 } } : { py: 1.5, px: { xs: 2, md: 3 } };
return (
<>
{layout === "single" ? (
@@ -32,9 +32,8 @@ export const CippPropertyList = (props) => {
key={`${index}-index-PropertyListOffCanvas`}
align={align}
label={item.label}
- value={ }
+ value={ }
sx={setPadding}
- {...item}
/>
))}
>
@@ -75,7 +74,7 @@ export const CippPropertyList = (props) => {
key={`${index}-index-PropertyListOffCanvas`}
align={align}
label={item.label}
- value={ }
+ value={ }
sx={setPadding}
/>
))}
@@ -101,12 +100,8 @@ export const CippPropertyList = (props) => {
key={`${index}-index-PropertyListOffCanvas`}
align={align}
label={item.label}
- value={ }
- sx={() => {
- if (item?.label === "" || item?.label === undefined || item?.label === null) {
- return { py: 0 };
- }
- }}
+ value={ }
+ sx={setPadding}
/>
))}
>
diff --git a/src/components/CippComponents/CippQuarantineDetails.jsx b/src/components/CippComponents/CippQuarantineDetails.jsx
new file mode 100644
index 000000000000..1e39e0e91fdb
--- /dev/null
+++ b/src/components/CippComponents/CippQuarantineDetails.jsx
@@ -0,0 +1,424 @@
+import {
+ Accordion,
+ AccordionDetails,
+ AccordionSummary,
+ Chip,
+ Stack,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableRow,
+ Typography,
+} from '@mui/material'
+import { ExpandMore } from '@mui/icons-material'
+import { CippPropertyList } from './CippPropertyList'
+import { CippCopyToClipBoard } from './CippCopyToClipboard'
+import { getCippFormatting } from '../../utils/get-cipp-formatting'
+import { ApiGetCall } from '../../api/ApiCall'
+import { useSettings } from '../../hooks/use-settings'
+
+// Convert camelCase/underscore Graph enum values to readable text, e.g. 'softFail' -> 'Soft fail'
+const formatEnum = (value) => {
+ if (typeof value !== 'string' || value === '') return value
+ const spaced = value.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/_/g, ' ')
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase()
+}
+
+const releaseStatusLabels = {
+ NOTRELEASED: 'Not released',
+ RELEASED: 'Released',
+ REQUESTED: 'Release requested',
+ DENIED: 'Release denied',
+ PREPARING: 'Preparing',
+ ERROR: 'Error',
+}
+
+const joinList = (value) =>
+ Array.isArray(value) ? value.filter(Boolean).join(', ') : value
+
+const threatChipColor = (threatType) => {
+ // Match on substrings: the same threat arrives in different forms depending on the source,
+ // e.g. 'HighConfPhish' (enum) vs 'High Confidence Phish' (Exchange display value).
+ const threat = String(threatType ?? '').toLowerCase()
+ if (!threat) return 'default'
+ if (threat.includes('malware') || threat.includes('phish')) return 'error'
+ if (threat.includes('spam') || threat.includes('bulk')) return 'warning'
+ return 'default'
+}
+
+const formatBytes = (bytes) => {
+ if (typeof bytes !== 'number' || Number.isNaN(bytes)) return bytes
+ if (bytes < 1024) return `${bytes} B`
+ let value = bytes
+ let unit = 'B'
+ for (const nextUnit of ['KB', 'MB', 'GB']) {
+ value = value / 1024
+ unit = nextUnit
+ if (value < 1024) break
+ }
+ return `${value.toFixed(1)} ${unit}`
+}
+
+const hasValue = (value) => {
+ if (value === undefined || value === null || value === '') return false
+ if (Array.isArray(value) && value.length === 0) return false
+ return true
+}
+
+const buildProperties = (fields) =>
+ fields
+ .filter(({ value }) => hasValue(value))
+ .map(({ label, value, field }) => ({
+ label,
+ value: field ? getCippFormatting(value, field) : value,
+ }))
+
+const Section = ({
+ title,
+ isFetching = false,
+ fields,
+ children,
+ defaultExpanded = true,
+}) => {
+ // While fetching, show label-only skeleton rows; otherwise drop empty fields entirely
+ const propertyItems = fields
+ ? isFetching
+ ? fields.map(({ label }) => ({ label }))
+ : buildProperties(fields)
+ : []
+ if (!isFetching && propertyItems.length === 0 && !children) return null
+ return (
+
+ }>
+ {title}
+
+
+ {children ?? (
+
+ )}
+
+
+ )
+}
+
+export const CippQuarantineDetails = ({ row }) => {
+ const currentTenant = useSettings().currentTenant
+ // The Defender lookup must target the tenant the message belongs to (AllTenants view)
+ const tenantFilter = row?.Tenant ?? currentTenant
+ const isEmail = (row?.EntityType ?? 'Email') === 'Email'
+ const networkMessageId =
+ row?.NetworkMessageId ?? row?.Identity?.split('\\')[0]
+ const recipient = Array.isArray(row?.RecipientAddress)
+ ? row.RecipientAddress[0]
+ : row?.RecipientAddress
+
+ const details = ApiGetCall({
+ url: '/api/ListMailQuarantineMessageDetails',
+ data: {
+ tenantFilter: tenantFilter,
+ NetworkMessageId: networkMessageId,
+ RecipientAddress: recipient,
+ ReceivedTime: row?.ReceivedTime,
+ Identity: row?.Identity,
+ },
+ waiting: Boolean(row && isEmail && networkMessageId && tenantFilter),
+ queryKey: `QuarantineMessageDetails-${tenantFilter}-${networkMessageId}-${recipient}`,
+ })
+
+ if (!row) return null
+
+ const analyzed =
+ details.data?.Results?.find(
+ (entry) =>
+ entry.recipientEmailAddress?.toLowerCase() === recipient?.toLowerCase()
+ ) ?? details.data?.Results?.[0]
+ const isEnriching = isEmail && details.isFetching
+ const enrichmentUnavailable = isEmail && details.isSuccess && !analyzed
+ const headerFallback = isEmail && details.data?.Metadata?.Source === 'Headers'
+
+ const quarantineFields = [
+ { label: 'Received', value: row.ReceivedTime, field: 'ReceivedTime' },
+ { label: 'Expires', value: row.Expires, field: 'Expires' },
+ { label: 'Subject', value: row.Subject },
+ { label: 'Quarantine Reason', value: row.Type },
+ { label: 'Policy Type', value: row.PolicyType },
+ { label: 'Policy Name', value: row.PolicyName },
+ {
+ label: 'Release Status',
+ value: releaseStatusLabels[row.ReleaseStatus] ?? row.ReleaseStatus,
+ },
+ { label: 'Released By', value: row.ReleasedUser, field: 'ReleasedUser' },
+ {
+ label: 'Quarantined User',
+ value: row.QuarantinedUser,
+ field: 'QuarantinedUser',
+ },
+ { label: 'Reported', value: row.Reported, field: 'Reported' },
+ {
+ label: 'Override Sources',
+ value: joinList(analyzed?.overrideSources?.map(formatEnum)),
+ },
+ ]
+
+ const deliveryFields = [
+ {
+ label: 'Original Threats',
+ value: analyzed?.originalDelivery?.originalThreats,
+ },
+ { label: 'Latest Threats', value: analyzed?.latestDelivery?.latestThreats },
+ {
+ label: 'Original Location',
+ value: formatEnum(analyzed?.originalDelivery?.location),
+ },
+ {
+ label: 'Latest Delivery Location',
+ value: formatEnum(analyzed?.latestDelivery?.location),
+ },
+ {
+ label: 'Delivery Action',
+ value: formatEnum(analyzed?.originalDelivery?.action),
+ },
+ {
+ label: 'Latest Delivery Action',
+ value: formatEnum(analyzed?.latestDelivery?.action),
+ },
+ {
+ label: 'Detection Technologies',
+ value: joinList(analyzed?.detectionMethods),
+ },
+ {
+ label: 'Threat Types',
+ value: joinList(
+ analyzed?.threatTypes
+ ?.filter((threat) => !['none', 'unknown'].includes(threat))
+ .map(formatEnum)
+ ),
+ },
+ {
+ label: 'Primary Override Source',
+ value: formatEnum(analyzed?.primaryOverrideSource),
+ },
+ { label: 'Policy Action', value: formatEnum(analyzed?.policyAction) },
+ { label: 'Phish Confidence Level', value: analyzed?.phishConfidenceLevel },
+ { label: 'Spam Confidence Level', value: analyzed?.spamConfidenceLevel },
+ { label: 'Bulk Complaint Level', value: analyzed?.bulkComplaintLevel },
+ ]
+
+ const emailFields = [
+ {
+ label: 'Sender Display Name',
+ value: analyzed?.senderDetail?.displayName,
+ },
+ {
+ label: 'Sender Address',
+ value: analyzed?.senderDetail?.mailFromAddress ?? row.SenderAddress,
+ },
+ {
+ label: 'Sender Mail From Address',
+ value: analyzed?.senderDetail?.fromAddress,
+ },
+ { label: 'Return Path', value: analyzed?.returnPath },
+ { label: 'Sender IP', value: analyzed?.senderDetail?.ipv4 },
+ { label: 'Sender Location', value: analyzed?.senderDetail?.location },
+ {
+ label: 'Recipient(s)',
+ value: row.RecipientAddress,
+ field: 'RecipientAddress',
+ },
+ { label: 'Distribution List', value: analyzed?.distributionList },
+ {
+ label: 'Direction',
+ value: formatEnum(analyzed?.directionality) ?? row.Direction,
+ },
+ { label: 'Network Message ID', value: networkMessageId },
+ {
+ label: 'Internet Message ID',
+ value: analyzed?.internetMessageId ?? row.MessageId,
+ },
+ { label: 'Size', value: row.Size, field: 'Size' },
+ { label: 'Language', value: analyzed?.language },
+ { label: 'Entity Type', value: row.EntityType },
+ { label: 'Teams Conversation Type', value: row.TeamsConversationType },
+ ]
+
+ const authenticationFields = [
+ {
+ label: 'DMARC',
+ value: formatEnum(analyzed?.authenticationDetails?.dmarc),
+ },
+ { label: 'DKIM', value: formatEnum(analyzed?.authenticationDetails?.dkim) },
+ {
+ label: 'SPF',
+ value: formatEnum(analyzed?.authenticationDetails?.senderPolicyFramework),
+ },
+ {
+ label: 'Composite Authentication',
+ value: formatEnum(
+ analyzed?.authenticationDetails?.compositeAuthentication
+ ),
+ },
+ ]
+
+ return (
+
+
+ {row.Subject}
+
+ {hasValue(row.Type) && (
+
+ )}
+ {hasValue(row.ReleaseStatus) && (
+
+ )}
+ {analyzed?.attachments?.length > 0 && (
+
+ )}
+ {analyzed?.urls?.length > 0 && (
+
+ )}
+
+ {enrichmentUnavailable && (
+
+ Extended threat details are unavailable for this message (requires
+ Microsoft Defender for Office 365).
+
+ )}
+ {headerFallback && (
+
+ Showing details parsed from the message headers and message
+ contents. Microsoft per-URL and per-attachment threat verdicts
+ require Microsoft Defender for Office 365 Plan 2.
+
+ )}
+
+
+
+
+
+
+ {analyzed?.urls?.length > 0 && (
+
+
+
+
+ URL
+ Threat
+ Detection Method
+
+
+
+ {analyzed.urls.map((urlEntry, index) => (
+
+
+ {urlEntry.url}
+
+
+
+
+
+ {urlEntry.detectionMethod}
+
+ ))}
+
+
+
+ )}
+ {analyzed?.attachments?.length > 0 && (
+
+
+
+
+ File Name
+ Threat
+ Malware Family
+ Size
+ SHA256
+
+
+
+ {analyzed.attachments.map((attachment, index) => (
+
+
+ {attachment.fileName}
+
+
+
+
+ {attachment.malwareFamily}
+ {formatBytes(attachment.fileSize)}
+
+ {attachment.sha256 && (
+
+ )}
+
+
+ ))}
+
+
+
+ )}
+
+
+ )
+}
+
+export default CippQuarantineDetails
diff --git a/src/components/CippComponents/CippQuarantineTable.jsx b/src/components/CippComponents/CippQuarantineTable.jsx
new file mode 100644
index 000000000000..0f59f695fda9
--- /dev/null
+++ b/src/components/CippComponents/CippQuarantineTable.jsx
@@ -0,0 +1,555 @@
+import { useEffect, useState } from 'react'
+import {
+ CircularProgress,
+ Dialog,
+ DialogContent,
+ DialogTitle,
+ IconButton,
+ Skeleton,
+ Typography,
+} from '@mui/material'
+import { Block, Close, Done, DoneAll } from '@mui/icons-material'
+import {
+ ArrowDownTrayIcon,
+ ArrowTopRightOnSquareIcon,
+ CodeBracketIcon,
+ DocumentTextIcon,
+ EyeIcon,
+ FlagIcon,
+ NoSymbolIcon,
+ TrashIcon,
+} from '@heroicons/react/24/outline'
+import { CippTablePage } from './CippTablePage.jsx'
+import { CippMessageViewer } from './CippMessageViewer.jsx'
+import { CippQuarantineDetails } from './CippQuarantineDetails.jsx'
+import { CippDataTable } from '../CippTable/CippDataTable'
+import { ApiGetCall, ApiPostCall } from '../../api/ApiCall'
+import { useSettings } from '../../hooks/use-settings'
+
+const traceDetailColumns = [
+ 'Received',
+ 'Status',
+ 'SenderAddress',
+ 'RecipientAddress',
+]
+
+const releaseStatusFilters = [
+ {
+ filterName: 'Not Released',
+ value: [{ id: 'ReleaseStatus', value: 'NOTRELEASED' }],
+ type: 'column',
+ filterType: 'equal',
+ },
+ {
+ filterName: 'Released',
+ value: [{ id: 'ReleaseStatus', value: 'RELEASED' }],
+ type: 'column',
+ filterType: 'equal',
+ },
+ {
+ filterName: 'Requested',
+ value: [{ id: 'ReleaseStatus', value: 'REQUESTED' }],
+ type: 'column',
+ filterType: 'equal',
+ },
+]
+
+const quarantineReasonFilters = [
+ { filterName: 'High Confidence Phishing', value: 'HighConfPhish' },
+ { filterName: 'Phishing', value: 'Phish' },
+ { filterName: 'Spam', value: 'Spam' },
+ { filterName: 'Malware', value: 'Malware' },
+ { filterName: 'Bulk', value: 'Bulk' },
+ { filterName: 'Transport Rule', value: 'TransportRule' },
+].map(({ filterName, value }) => ({
+ filterName,
+ value: [{ id: 'Type', value }],
+ type: 'column',
+ filterType: 'equal',
+}))
+
+const pageTitles = {
+ Email: 'Quarantine - Email',
+ SharePointOnline: 'Quarantine - Files',
+ Teams: 'Quarantine - Teams Messages',
+}
+
+export const CippQuarantineTable = ({ entityType = 'Email' }) => {
+ const tenantFilter = useSettings().currentTenant
+ const isEmail = entityType === 'Email'
+ const queryKey = `MailQuarantine-${entityType}-${tenantFilter}`
+
+ // In the AllTenants view each row belongs to a different tenant (row.Tenant); per-message
+ // actions must target that tenant rather than the page-level "AllTenants" selection. Falls back
+ // to the page tenant for the normal single-tenant view.
+ const resolveTenant = (row) =>
+ tenantFilter === 'AllTenants' ? (row?.Tenant ?? tenantFilter) : tenantFilter
+
+ // Preview message dialog
+ const [messageRow, setMessageRow] = useState(null)
+ const [dialogOpen, setDialogOpen] = useState(false)
+
+ // Message headers dialog
+ const [headerRow, setHeaderRow] = useState(null)
+ const [headerDialogOpen, setHeaderDialogOpen] = useState(false)
+
+ // Download message state
+ const [downloadRow, setDownloadRow] = useState(null)
+
+ // Message trace dialog
+ const [traceDialogOpen, setTraceDialogOpen] = useState(false)
+ const [traceDetails, setTraceDetails] = useState([])
+ const [traceMessageId, setTraceMessageId] = useState(null)
+ const [traceTenant, setTraceTenant] = useState(null)
+ const [messageSubject, setMessageSubject] = useState(null)
+
+ const messageTenant = resolveTenant(messageRow)
+ const getMessageContents = ApiGetCall({
+ url: '/api/ListMailQuarantineMessage',
+ data: {
+ tenantFilter: messageTenant,
+ Identity: messageRow?.Identity,
+ },
+ waiting: Boolean(messageRow),
+ queryKey: `ListMailQuarantineMessage-${messageTenant}-${messageRow?.Identity}`,
+ })
+
+ const headerTenant = resolveTenant(headerRow)
+ const getMessageHeaders = ApiGetCall({
+ url: '/api/ListMailQuarantineMessageHeader',
+ data: {
+ tenantFilter: headerTenant,
+ Identity: headerRow?.Identity,
+ },
+ waiting: Boolean(headerRow),
+ queryKey: `ListMailQuarantineMessageHeader-${headerTenant}-${headerRow?.Identity}`,
+ })
+
+ const downloadTenant = resolveTenant(downloadRow)
+ const getMessageDownload = ApiGetCall({
+ url: '/api/ListMailQuarantineMessage',
+ data: {
+ tenantFilter: downloadTenant,
+ Identity: downloadRow?.Identity,
+ },
+ waiting: Boolean(downloadRow),
+ queryKey: `ListMailQuarantineMessageDownload-${downloadTenant}-${downloadRow?.Identity}`,
+ })
+
+ const getMessageTraceDetails = ApiPostCall({
+ urlFromData: true,
+ queryKey: `MessageTraceDetail-${traceTenant}-${traceMessageId}`,
+ onResult: (result) => {
+ setTraceDetails(result)
+ },
+ })
+
+ // CippPropertyListCard calls customFunction(actionItem, rowData, {}); table rows call
+ // customFunction(rowData). Accept both signatures by detecting which arg carries Identity.
+ const resolveRow = (...args) => (args[0]?.Identity ? args[0] : args[1])
+
+ const viewMessage = (...args) => {
+ const row = resolveRow(...args)
+ setMessageRow(row)
+ setDialogOpen(true)
+ }
+
+ const viewHeaders = (...args) => {
+ const row = resolveRow(...args)
+ setHeaderRow(row)
+ setHeaderDialogOpen(true)
+ }
+
+ const downloadMessage = (...args) => {
+ const row = resolveRow(...args)
+ setDownloadRow(row)
+ }
+
+ const viewMessageTrace = (...args) => {
+ const row = resolveRow(...args)
+ const rowTenant = resolveTenant(row)
+ setTraceTenant(rowTenant)
+ setTraceMessageId(row.MessageId)
+ getMessageTraceDetails.mutate({
+ url: '/api/ListMessageTrace',
+ data: {
+ tenantFilter: rowTenant,
+ messageId: row.MessageId,
+ },
+ })
+ setMessageSubject(row.Subject)
+ setTraceDialogOpen(true)
+ }
+
+ const openInDefender = (...args) => {
+ const row = resolveRow(...args)
+ const networkMessageId =
+ row.NetworkMessageId ?? row.Identity?.split('\\')[0]
+ const recipient = Array.isArray(row.RecipientAddress)
+ ? row.RecipientAddress[0]
+ : row.RecipientAddress
+ const receivedTime = row.ReceivedTime
+ ? new Date(row.ReceivedTime).toISOString()
+ : ''
+ let url =
+ `https://security.microsoft.com/emailentityV2?id=${encodeURIComponent(networkMessageId)}` +
+ `&recipient=${encodeURIComponent(recipient ?? '')}` +
+ `&startTime=${encodeURIComponent(receivedTime)}` +
+ `&endTime=${encodeURIComponent(receivedTime)}` +
+ `&contentonly=1` +
+ `&subject=${encodeURIComponent(row.Subject ?? '')}` +
+ `&entityId=${encodeURIComponent(`${networkMessageId}_${recipient ?? ''}`)}`
+ if (row.CustomerId) {
+ url += `&tid=${row.CustomerId}`
+ }
+ window.open(url, '_blank')
+ }
+
+ useEffect(() => {
+ if (
+ downloadRow &&
+ getMessageDownload.isSuccess &&
+ getMessageDownload.data?.Message
+ ) {
+ const networkMessageId =
+ downloadRow.NetworkMessageId ?? downloadRow.Identity?.split('\\')[0]
+ const fileName = `${(
+ downloadRow.Subject ||
+ networkMessageId ||
+ 'quarantined-message'
+ )
+ .replace(/[\\/:*?"<>|]/g, '_')
+ .slice(0, 100)}.eml`
+ // Use the raw base64 export when available to preserve non-UTF-8 MIME content
+ const emlBase64 = getMessageDownload.data.EmlBase64
+ let blob
+ if (emlBase64) {
+ const bytes = Uint8Array.from(atob(emlBase64), (c) => c.charCodeAt(0))
+ blob = new Blob([bytes], { type: 'message/rfc822' })
+ } else {
+ blob = new Blob([getMessageDownload.data.Message], {
+ type: 'message/rfc822',
+ })
+ }
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement('a')
+ link.href = url
+ link.download = fileName
+ link.click()
+ URL.revokeObjectURL(url)
+ setDownloadRow(null)
+ }
+ }, [getMessageDownload.isSuccess, getMessageDownload.data, downloadRow])
+
+ const actions = [
+ {
+ label: 'Release',
+ type: 'POST',
+ url: '/api/ExecQuarantineManagement',
+ multiPost: true,
+ data: {
+ tenantFilter: 'Tenant',
+ Identity: 'Identity',
+ Type: '!Release',
+ },
+ confirmText: 'Are you sure you want to release this message?',
+ icon: ,
+ condition: (row) => row.ReleaseStatus !== 'RELEASED',
+ },
+ ...(isEmail
+ ? [
+ {
+ label: 'Release & Allow Sender',
+ type: 'POST',
+ url: '/api/ExecQuarantineManagement',
+ multiPost: true,
+ data: {
+ tenantFilter: 'Tenant',
+ Identity: 'Identity',
+ Type: '!Release',
+ AllowSender: true,
+ SenderAddress: 'SenderAddress',
+ PolicyName: 'PolicyName',
+ },
+ confirmText:
+ 'Are you sure you want to release this email and add the sender to the whitelist?',
+ icon: ,
+ condition: (row) => row.ReleaseStatus !== 'RELEASED',
+ },
+ {
+ label: 'Deny',
+ type: 'POST',
+ url: '/api/ExecQuarantineManagement',
+ multiPost: true,
+ data: {
+ tenantFilter: 'Tenant',
+ Identity: 'Identity',
+ Type: '!Deny',
+ RecipientAddress: 'RecipientAddress',
+ },
+ confirmText: 'Are you sure you want to deny this message?',
+ icon: ,
+ condition: (row) => row.ReleaseStatus === 'REQUESTED',
+ },
+ ]
+ : []),
+ {
+ label: 'Delete from Quarantine',
+ type: 'POST',
+ url: '/api/ExecQuarantineManagement',
+ multiPost: true,
+ data: {
+ tenantFilter: 'Tenant',
+ Identity: 'Identity',
+ Type: '!Delete',
+ },
+ confirmText:
+ 'Are you sure you want to permanently delete this message from quarantine? This action cannot be undone.',
+ icon: ,
+ color: 'danger',
+ condition: (row) => row.ReleaseStatus !== 'RELEASED',
+ },
+ ...(isEmail
+ ? [
+ {
+ label: 'Preview Message',
+ noConfirm: true,
+ customFunction: viewMessage,
+ icon: ,
+ hideBulk: true,
+ },
+ {
+ label: 'View Message Headers',
+ noConfirm: true,
+ customFunction: viewHeaders,
+ icon: ,
+ hideBulk: true,
+ },
+ {
+ label: 'Download Message (.eml)',
+ noConfirm: true,
+ customFunction: downloadMessage,
+ icon: ,
+ hideBulk: true,
+ },
+ {
+ label: 'View Message Trace',
+ noConfirm: true,
+ customFunction: viewMessageTrace,
+ icon: ,
+ hideBulk: true,
+ },
+ {
+ label: 'Submit to Microsoft for Review',
+ type: 'POST',
+ url: '/api/ExecMailQuarantineSubmit',
+ data: {
+ tenantFilter: 'Tenant',
+ Identity: 'Identity',
+ RecipientAddress: 'RecipientAddress',
+ },
+ fields: [
+ {
+ type: 'autoComplete',
+ name: 'category',
+ label: 'Submission category',
+ multiple: false,
+ creatable: false,
+ options: [
+ {
+ label: 'Clean - should not have been quarantined',
+ value: 'notJunk',
+ },
+ { label: 'Spam', value: 'spam' },
+ { label: 'Phishing', value: 'phishing' },
+ { label: 'Malware', value: 'malware' },
+ ],
+ validators: { required: 'Please select a category' },
+ },
+ ],
+ confirmText: 'Submit "[Subject]" to Microsoft for analysis?',
+ icon: ,
+ hideBulk: true,
+ },
+ {
+ label: 'Block Sender',
+ type: 'POST',
+ url: '/api/AddTenantAllowBlockList',
+ data: {
+ tenantID: 'Tenant',
+ entries: 'SenderAddress',
+ listType: '!Sender',
+ listMethod: '!Block',
+ },
+ fields: [
+ {
+ type: 'switch',
+ name: 'NoExpiration',
+ label: 'Never expire (default: expires after 30 days)',
+ },
+ {
+ type: 'textField',
+ name: 'notes',
+ label: 'Notes (optional)',
+ },
+ ],
+ confirmText:
+ 'Block sender [SenderAddress] by adding an entry to the Tenant Allow/Block List?',
+ icon: ,
+ },
+ {
+ label: 'Open Email Entity in Defender',
+ noConfirm: true,
+ customFunction: openInDefender,
+ icon: ,
+ hideBulk: true,
+ },
+ ]
+ : []),
+ ]
+
+ const offCanvas = {
+ size: 'lg',
+ actions: actions,
+ actionsPosition: 'bottom',
+ children: (row) => ,
+ }
+
+ const filterList = isEmail
+ ? [...releaseStatusFilters, ...quarantineReasonFilters]
+ : releaseStatusFilters
+
+ const simpleColumns = [
+ 'ReceivedTime',
+ 'Subject',
+ 'SenderAddress',
+ 'Type',
+ 'ReleaseStatus',
+ 'PolicyType',
+ 'Expires',
+ 'RecipientAddress',
+ 'ReleasedUser',
+ 'Tenant',
+ ]
+
+ return (
+ <>
+
+ setDialogOpen(false)}
+ maxWidth="lg"
+ fullWidth
+ >
+
+ Quarantine Message
+ setDialogOpen(false)}
+ sx={{ position: 'absolute', right: 8, top: 8 }}
+ >
+
+
+
+
+ {getMessageContents.isSuccess ? (
+
+ ) : (
+
+ )}
+
+
+ setHeaderDialogOpen(false)}
+ maxWidth="lg"
+ fullWidth
+ >
+
+ Message Headers - {headerRow?.Subject}
+ setHeaderDialogOpen(false)}
+ sx={{ position: 'absolute', right: 8, top: 8 }}
+ >
+
+
+
+
+ {getMessageHeaders.isSuccess ? (
+
+ {getMessageHeaders?.data?.Header}
+
+ ) : (
+
+ )}
+
+
+ setTraceDialogOpen(false)}
+ maxWidth="lg"
+ fullWidth
+ >
+
+ Message Trace - {messageSubject}
+ setTraceDialogOpen(false)}
+ sx={{ position: 'absolute', right: 8, top: 8 }}
+ >
+
+
+
+
+ {getMessageTraceDetails.isPending && (
+
+ {' '}
+ Loading message trace details...
+
+ )}
+ {getMessageTraceDetails.isSuccess && (
+
+ getMessageTraceDetails.mutate({
+ url: '/api/ListMessageTrace',
+ data: {
+ tenantFilter: traceTenant,
+ messageId: traceMessageId,
+ },
+ })
+ }
+ isFetching={getMessageTraceDetails.isPending}
+ />
+ )}
+
+
+ >
+ )
+}
+
+export default CippQuarantineTable
diff --git a/src/components/CippComponents/CippReportToolbar.jsx b/src/components/CippComponents/CippReportToolbar.jsx
index 30d18a6d3a66..9b635e592ca5 100644
--- a/src/components/CippComponents/CippReportToolbar.jsx
+++ b/src/components/CippComponents/CippReportToolbar.jsx
@@ -1,22 +1,53 @@
-import { Box, Button, Tooltip } from '@mui/material'
+import {
+ Box,
+ Button,
+ ButtonBase,
+ IconButton,
+ List,
+ ListItemButton,
+ ListItemIcon,
+ ListItemText,
+ Tooltip,
+ Typography,
+} from '@mui/material'
+import { visuallyHidden } from '@mui/utils'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/router'
import { useForm, useWatch } from 'react-hook-form'
import { useSettings } from '../../hooks/use-settings'
+import { useIsMobileLayout } from '../../hooks/use-breakpoint'
import { ApiGetCall } from '../../api/ApiCall.jsx'
import { useQueryClient } from '@tanstack/react-query'
-import { Refresh as RefreshIcon, Delete as DeleteIcon } from '@mui/icons-material'
+import {
+ Add,
+ Check,
+ Delete as DeleteIcon,
+ Edit,
+ KeyboardArrowDown,
+ MoreVert,
+ Refresh as RefreshIcon,
+ Sync,
+} from '@mui/icons-material'
import CippFormComponent from './CippFormComponent'
import { CippAddTestReportDrawer } from './CippAddTestReportDrawer'
import { CippApiDialog } from './CippApiDialog'
+import { CippBottomSheet } from './CippBottomSheet'
+import { useSheetHandoff } from '../../hooks/use-sheet-handoff'
export const CippReportToolbar = () => {
const settings = useSettings()
const router = useRouter()
const { currentTenant } = settings
const queryClient = useQueryClient()
+ const isMobile = useIsMobileLayout()
const [deleteDialog, setDeleteDialog] = useState({ open: false })
const [refreshDialog, setRefreshDialog] = useState({ open: false })
+ const [actionSheetOpen, setActionSheetOpen] = useState(false)
+ const [suiteSheetOpen, setSuiteSheetOpen] = useState(false)
+ // Every row here opens a drawer or dialog — let the sheet close first
+ const actionSheet = useSheetHandoff(() => setActionSheetOpen(false))
+ const [createDrawerOpen, setCreateDrawerOpen] = useState(false)
+ const [editDrawerOpen, setEditDrawerOpen] = useState(false)
const defaultReportId =
settings.UserSpecificSettings?.defaultTestSuite?.value ||
@@ -73,105 +104,276 @@ export const CippReportToolbar = () => {
const isBuiltIn = selectedReportObject?.source === 'file'
const selectedCustomReport = selectedReportObject?.type === 'custom' ? selectedReportObject : null
+ const openRefreshDialog = () => {
+ setRefreshDialog({
+ open: true,
+ handleClose: () => setRefreshDialog({ open: false }),
+ })
+ }
+
+ const openDeleteDialog = () => {
+ const report = reports.find((r) => r.id === selectedReport)
+ if (report) {
+ setDeleteDialog({
+ open: true,
+ handleClose: () => setDeleteDialog({ open: false }),
+ row: { ReportId: selectedReport, name: report.name },
+ })
+ }
+ }
+
+ const suiteSelector = (withRefreshAction) => (
+ ({
+ label: r.name,
+ value: r.id,
+ description: r.description,
+ }))}
+ placeholder="Choose a test suite"
+ {...(withRefreshAction && {
+ customAction: {
+ position: 'outside',
+ icon: ,
+ tooltip: 'Refresh test suites',
+ onClick: handleRefresh,
+ },
+ })}
+ isFetching={reportsApi.isFetching}
+ />
+ )
+
return (
<>
-
-
- ({
- label: r.name,
- value: r.id,
- description: r.description,
- }))}
- placeholder="Choose a test suite"
- customAction={{
- position: 'outside',
- icon: ,
- tooltip: 'Refresh test suites',
- onClick: handleRefresh,
+ {isMobile ? (
+ // Trigger + kebab only; picking a suite and the suite actions are both bottom
+ // sheets — the house pick-one pattern, so no keyboard is summoned for a list nobody
+ // types into. The overlays the actions open are mounted below, outside the sheet.
+
+ setSuiteSheetOpen(true)}
+ aria-haspopup="dialog"
+ sx={{
+ flex: 1,
+ minWidth: 0,
+ height: 44,
+ display: 'flex',
+ alignItems: 'center',
+ gap: 0.75,
+ px: 1.5,
+ borderRadius: 1,
+ border: 1,
+ borderColor: 'divider',
+ bgcolor: 'background.paper',
+ textAlign: 'left',
}}
- isFetching={reportsApi.isFetching}
- />
+ >
+
+ {selectedReportObject?.name ?? 'Select a test suite'}
+
+
+ switch test suite
+
+
+
+ setActionSheetOpen(true)}
+ sx={{ minWidth: 44, minHeight: 44 }}
+ >
+
+
-
- {
- setRefreshDialog({
- open: true,
- handleClose: () => setRefreshDialog({ open: false }),
- })
- }}
- startIcon={ }
- >
- Refresh
-
-
-
-
-
-
-
+ {/* minWidth: 0 lets the selector shrink when the row is tight instead of pushing
+ the trailing buttons off-screen. Layout is unchanged at widths where it fit. */}
+ {suiteSelector(true)}
+
+ }
+ >
+ Refresh
+
+
+
+
+
+
+
+
+ }
+ sx={{
+ fontWeight: 'bold',
+ textTransform: 'none',
+ borderRadius: 2,
+ boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
+ transition: 'all 0.2s ease-in-out',
+ }}
+ onClick={openDeleteDialog}
+ >
+ Delete
+
+
+
+
+ )}
+
+ {isMobile && (
+ setSuiteSheetOpen(false)}
+ title="Test suite"
>
-
- }
- sx={{
- fontWeight: 'bold',
- textTransform: 'none',
- borderRadius: 2,
- boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
- transition: 'all 0.2s ease-in-out',
- }}
- onClick={() => {
- const report = reports.find((r) => r.id === selectedReport)
- if (report) {
- setDeleteDialog({
- open: true,
- handleClose: () => setDeleteDialog({ open: false }),
- row: { ReportId: selectedReport, name: report.name },
- })
- }
- }}
- >
- Delete
-
-
-
-
+
+ {reports.map((report) => {
+ const selected = report.id === selectedReport
+ return (
+ {
+ setSuiteSheetOpen(false)
+ if (!selected) {
+ // Same write the autocomplete made — the routing effect owns the push
+ formControl.setValue('reportId', { value: report.id, label: report.name })
+ }
+ }}
+ >
+
+ {selected && }
+
+ )
+ })}
+
+
+ )}
+ {isMobile && (
+ <>
+
+
+ actionSheet.run(() => setCreateDrawerOpen(true))}
+ >
+
+
+
+
+
+ actionSheet.run(() => openRefreshDialog())}
+ >
+
+
+
+
+
+ actionSheet.run(() => setEditDrawerOpen(true))}
+ >
+
+
+
+
+
+ actionSheet.run(() => openDeleteDialog())}
+ >
+
+
+
+
+
+ actionSheet.run(() => handleRefresh())}
+ >
+
+
+
+
+
+
+
+ setCreateDrawerOpen(false)}
+ />
+ setEditDrawerOpen(false)}
+ />
+ >
+ )}
-
+
{categories
.filter((c) => selectedCategories[c.key])
.map((c) => (
diff --git a/src/components/CippComponents/CippSankey.jsx b/src/components/CippComponents/CippSankey.jsx
index f22f091e80cc..44d46736a770 100644
--- a/src/components/CippComponents/CippSankey.jsx
+++ b/src/components/CippComponents/CippSankey.jsx
@@ -1,9 +1,36 @@
+import { useMemo } from "react";
import { ResponsiveSankey } from "@nivo/sankey";
-import { useSettings } from "../../hooks/use-settings";
+import { Box, ButtonBase, Typography, useTheme } from "@mui/material";
+import { useIsMobileLayout } from "../../hooks/use-breakpoint";
+
+// A node's weight: what flows in, or out if nothing flows in (the leftmost column).
+const nodeTotals = (data) => {
+ const incoming = new Map();
+ const outgoing = new Map();
+ (data?.links ?? []).forEach((link) => {
+ incoming.set(link.target, (incoming.get(link.target) ?? 0) + (link.value ?? 0));
+ outgoing.set(link.source, (outgoing.get(link.source) ?? 0) + (link.value ?? 0));
+ });
+ return (data?.nodes ?? []).map((node) => ({
+ ...node,
+ total: incoming.get(node.id) ?? outgoing.get(node.id) ?? 0,
+ }));
+};
export const CippSankey = ({ data, onNodeClick, onLinkClick }) => {
- const settings = useSettings();
- const isDark = settings.currentTheme?.value === "dark";
+ // The painted palette, not the theme *setting*: when the setting is "browser" the app
+ // resolves dark/light from the OS preference, so checking the setting for "dark" said
+ // light while the page was dark — and a "multiply" blend over a dark card composites the
+ // link ribbons to black.
+ const isDark = useTheme().palette.mode === "dark";
+ // A sankey is three columns of nodes plus their labels. At desktop widths the labels sit
+ // horizontally inside an 18px-thick node and still read. On a ~350px card they cannot: a
+ // node carrying a handful of users is a couple of pixels tall, and its label — rotated or
+ // not — is longer than the node it belongs to, so the small ones pile on top of each other
+ // into an unreadable smear. Below md the chart drops its labels and names the nodes in a
+ // legend underneath, where there is room to read them and a real tap target per node.
+ const isMobile = useIsMobileLayout();
+ const legend = useMemo(() => (isMobile ? nodeTotals(data) : []), [isMobile, data]);
const theme = {
tooltip: {
@@ -19,7 +46,7 @@ export const CippSankey = ({ data, onNodeClick, onLinkClick }) => {
},
labels: {
text: {
- fontSize: 12,
+ fontSize: isMobile ? 9 : 12,
},
},
};
@@ -30,47 +57,118 @@ export const CippSankey = ({ data, onNodeClick, onLinkClick }) => {
style={{
height: "100%",
width: "100%",
+ display: "flex",
+ flexDirection: "column",
+ minHeight: 0,
cursor: onNodeClick || onLinkClick ? "pointer" : "default",
}}
>
- node.nodeColor}
- label={(node) => node.label ?? node.id}
- nodeOpacity={1}
- nodeHoverOthersOpacity={0.35}
- nodeThickness={18}
- nodeSpacing={24}
- nodeBorderWidth={0}
- nodeBorderColor={{
- from: "color",
- modifiers: [["darker", 0.8]],
- }}
- nodeBorderRadius={3}
- linkOpacity={0.5}
- linkHoverOthersOpacity={0.1}
- linkContract={3}
- linkBlendMode={isDark ? "lighten" : "multiply"}
- enableLinkGradient={true}
- labelPosition="inside"
- labelOrientation="horizontal"
- labelPadding={16}
- labelTextColor={isDark ? "#ffffff" : "#000000"}
- sort="input"
- legends={[]}
- valueFormat={(value) => `${value}`}
- isInteractive={true}
- onClick={(node, event) => {
- if (onNodeClick && node.id) {
- onNodeClick(node);
- } else if (onLinkClick && node.source) {
- onLinkClick(node);
+
+
+ align="justify"
+ colors={(node) => node.nodeColor}
+ label={(node) => node.label ?? node.id}
+ nodeOpacity={1}
+ nodeHoverOthersOpacity={0.35}
+ nodeThickness={isMobile ? 10 : 18}
+ nodeSpacing={isMobile ? 12 : 24}
+ nodeBorderWidth={0}
+ nodeBorderColor={{
+ from: "color",
+ modifiers: [["darker", 0.8]],
+ }}
+ nodeBorderRadius={3}
+ linkOpacity={isMobile ? 0.75 : 0.5}
+ linkHoverOthersOpacity={0.1}
+ // Contracting each end eats into the gap between node columns; on a narrow chart
+ // that gap is small enough that 3px a side visibly thins the ribbons.
+ linkContract={isMobile ? 0 : 3}
+ // mix-blend-mode on SVG is unreliable in mobile WebKit — combined with a gradient
+ // fill it can composite the ribbons to nothing, which shows as bare node bars with
+ // no links between them. Blend is decoration here, so mobile renders them plainly
+ // and leans on opacity instead.
+ linkBlendMode={isMobile ? "normal" : isDark ? "lighten" : "multiply"}
+ enableLinkGradient={!isMobile}
+ enableLabels={!isMobile}
+ labelPosition="inside"
+ labelOrientation={isMobile ? "vertical" : "horizontal"}
+ labelPadding={isMobile ? 6 : 16}
+ labelTextColor={isDark ? "#ffffff" : "#000000"}
+ sort="input"
+ legends={[]}
+ valueFormat={(value) => `${value}`}
+ isInteractive={true}
+ onClick={(node, event) => {
+ if (onNodeClick && node.id) {
+ onNodeClick(node);
+ } else if (onLinkClick && node.source) {
+ onLinkClick(node);
+ }
+ }}
+ />
+
+ {isMobile && legend.length > 0 && (
+
+ {legend.map((node) => (
+
+ onNodeClick?.(node)}
+ disabled={!onNodeClick}
+ sx={{
+ width: "100%",
+ minHeight: 28,
+ px: 0.5,
+ borderRadius: 0.5,
+ display: "flex",
+ alignItems: "center",
+ gap: 0.75,
+ textAlign: "left",
+ justifyContent: "flex-start",
+ }}
+ >
+
+
+ {node.label ?? node.id}
+
+
+ {node.total}
+
+
+
+ ))}
+
+ )}
);
};
diff --git a/src/components/CippComponents/CippSettingsSideBar.jsx b/src/components/CippComponents/CippSettingsSideBar.jsx
index a2b9635953d0..61da427d6379 100644
--- a/src/components/CippComponents/CippSettingsSideBar.jsx
+++ b/src/components/CippComponents/CippSettingsSideBar.jsx
@@ -60,6 +60,7 @@ export const CippSettingsSideBar = (props) => {
// General Settings
usageLocation: formValues.usageLocation,
tablePageSize: formValues.tablePageSize,
+ tableViewMode: formValues.tableViewMode,
defaultTestSuite: formValues.defaultTestSuite,
userAttributes: formValues.userAttributes,
@@ -107,6 +108,7 @@ export const CippSettingsSideBar = (props) => {
ClearImmutableId: formValues.offboardingDefaults?.ClearImmutableId,
removeCalendarPermissions: formValues.offboardingDefaults?.removeCalendarPermissions,
DisableOneDriveSharing: formValues.offboardingDefaults?.DisableOneDriveSharing,
+ OOO: formValues.offboardingDefaults?.OOO,
postExecution: {
psa: formValues.offboardingDefaults?.postExecution?.psa,
email: formValues.offboardingDefaults?.postExecution?.email,
diff --git a/src/components/CippComponents/CippSharePointBrowserBanner.jsx b/src/components/CippComponents/CippSharePointBrowserBanner.jsx
new file mode 100644
index 000000000000..6c2b7788270c
--- /dev/null
+++ b/src/components/CippComponents/CippSharePointBrowserBanner.jsx
@@ -0,0 +1,123 @@
+import PropTypes from 'prop-types'
+import { Box, Button, Card, Skeleton, Stack, Typography } from '@mui/material'
+import { Add, Edit, Security, Storage as StorageIcon } from '@mui/icons-material'
+import { ActionsMenu } from '../actions-menu'
+
+/**
+ * Top chrome for the SharePoint site browser: selection title on the left,
+ * bulk Actions + Storage + Permissions + contextual New / Edit Site on the right.
+ *
+ * Title rules:
+ * - site only → "SiteName"
+ * - site + library → "SiteName / LibraryName" (slash subdued)
+ * - nothing selected → placeholder
+ *
+ * Storage: when a site context is available (site-scoped reclaim).
+ * Permissions: only when a site or library row is selected.
+ * New button:
+ * - root → "New Site"
+ * - inside a site → "New Library"
+ * Edit Site: when a site is selected or drilled into a site (stub).
+ */
+export const CippSharePointBrowserBanner = ({
+ site,
+ library,
+ bulkActions = [],
+ selectedRows = [],
+ isFetching = false,
+ queryKeys,
+ atRoot = true,
+ showStorage = false,
+ onStorageClick,
+ showPermissions = false,
+ onPermissionsClick,
+ showEditSite = false,
+ onEditSiteClick,
+}) => {
+ const siteName = site?.displayName ?? null
+ const libraryName = library?.displayName ?? null
+ const hasTitle = Boolean(siteName || libraryName)
+ const showActions = selectedRows.length > 0 && bulkActions.length > 0
+ const newLabel = atRoot ? 'New Site' : 'New Library'
+
+ return (
+
+
+
+ {isFetching && !hasTitle ? (
+
+ ) : hasTitle ? (
+ <>
+ {siteName ?? 'Site'}
+ {libraryName ? (
+ <>
+
+ /
+
+ {libraryName}
+ >
+ ) : null}
+ >
+ ) : (
+
+ Select a site
+
+ )}
+
+
+ {showActions ? (
+ 1 ? 'Bulk Actions' : 'Actions'}
+ actions={bulkActions}
+ data={selectedRows}
+ queryKeys={queryKeys}
+ />
+ ) : null}
+ {showStorage ? (
+ } onClick={onStorageClick}>
+ Storage
+
+ ) : null}
+ {showPermissions ? (
+ } onClick={onPermissionsClick}>
+ Permissions
+
+ ) : null}
+ }>
+ {newLabel}
+
+ {showEditSite ? (
+ } onClick={onEditSiteClick}>
+ Edit Site
+
+ ) : null}
+
+
+
+ )
+}
+
+CippSharePointBrowserBanner.propTypes = {
+ site: PropTypes.object,
+ library: PropTypes.object,
+ bulkActions: PropTypes.array,
+ selectedRows: PropTypes.array,
+ isFetching: PropTypes.bool,
+ queryKeys: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
+ atRoot: PropTypes.bool,
+ showStorage: PropTypes.bool,
+ onStorageClick: PropTypes.func,
+ showPermissions: PropTypes.bool,
+ onPermissionsClick: PropTypes.func,
+ showEditSite: PropTypes.bool,
+ onEditSiteClick: PropTypes.func,
+}
diff --git a/src/components/CippComponents/CippSharePointBrowserPermissions.jsx b/src/components/CippComponents/CippSharePointBrowserPermissions.jsx
new file mode 100644
index 000000000000..ab3c86e7a381
--- /dev/null
+++ b/src/components/CippComponents/CippSharePointBrowserPermissions.jsx
@@ -0,0 +1,1637 @@
+import { useEffect, useMemo, useState } from 'react'
+import PropTypes from 'prop-types'
+import {
+ Alert,
+ AlertTitle,
+ Box,
+ Button,
+ Chip,
+ CircularProgress,
+ Dialog,
+ DialogContent,
+ DialogTitle,
+ Divider,
+ IconButton,
+ List,
+ ListItemButton,
+ ListItemText,
+ Skeleton,
+ Stack,
+ Tab,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Tabs,
+ Tooltip,
+ Typography,
+} from '@mui/material'
+import {
+ Add,
+ Close,
+ DeleteOutline,
+ EditOutlined,
+ LinkOff,
+ Link as LinkIcon,
+ PersonSearch,
+ Refresh,
+ Security,
+} from '@mui/icons-material'
+import { useForm } from 'react-hook-form'
+import { ApiGetCall } from '../../api/ApiCall'
+import { CippApiDialog } from './CippApiDialog'
+import CippFormComponent from './CippFormComponent'
+import { useDialog } from '../../hooks/use-dialog'
+import { usePermissions } from '../../hooks/use-permissions'
+
+const EMPTY = []
+
+const optionValue = (value) =>
+ value && typeof value === 'object' && 'value' in value ? value.value : value
+
+const TabPanel = ({ value, index, children }) =>
+ value === index ? {children} : null
+
+TabPanel.propTypes = {
+ value: PropTypes.number.isRequired,
+ index: PropTypes.number.isRequired,
+ children: PropTypes.node,
+}
+
+const SectionToolbar = ({
+ title,
+ count,
+ actions = EMPTY,
+}) => (
+
+
+ {title}
+ {typeof count === 'number' ? : null}
+
+ {actions.length ? (
+
+ {actions.map((action) => (
+
+
+ }
+ onClick={action.onClick}
+ disabled={action.disabled}
+ >
+ {action.label}
+
+
+
+ ))}
+
+ ) : null}
+
+)
+
+SectionToolbar.propTypes = {
+ title: PropTypes.string.isRequired,
+ count: PropTypes.number,
+ actions: PropTypes.arrayOf(
+ PropTypes.shape({
+ label: PropTypes.string.isRequired,
+ onClick: PropTypes.func,
+ disabled: PropTypes.bool,
+ disabledTitle: PropTypes.string,
+ icon: PropTypes.node,
+ })
+ ),
+}
+
+const RowActions = ({ onEdit, onRemove, disableActions = true, disabledTitle = 'Coming soon' }) => (
+
+ {onEdit ? (
+
+
+
+
+
+
+
+ ) : null}
+ {onRemove ? (
+
+
+
+
+
+
+
+ ) : null}
+
+)
+
+RowActions.propTypes = {
+ onEdit: PropTypes.func,
+ onRemove: PropTypes.func,
+ disableActions: PropTypes.bool,
+ disabledTitle: PropTypes.string,
+}
+
+const PrincipalChips = ({ row }) => (
+
+ {row.isGuest ? : null}
+ {row.isSiteAdmin ? : null}
+ {row.isSystemGroup ? : null}
+ {row.isSystemManaged ? : null}
+
+)
+
+PrincipalChips.propTypes = {
+ row: PropTypes.object.isRequired,
+}
+
+const EmptyState = ({ message = 'None' }) => (
+
+ {message}
+
+)
+
+EmptyState.propTypes = {
+ message: PropTypes.string,
+}
+
+const AccessTable = ({ rows = EMPTY, canWrite = false, systemGroupIds = EMPTY, onEdit, onRemove }) => {
+ const systemIds = useMemo(() => {
+ const set = new Set()
+ ;(Array.isArray(systemGroupIds) ? systemGroupIds : []).forEach((id) => {
+ if (id !== null && id !== undefined && `${id}`.length) set.add(`${id}`)
+ })
+ return set
+ }, [systemGroupIds])
+
+ if (!rows.length) return
+
+ return (
+
+
+
+
+ Principal
+ Type
+ Permission
+ Email / UPN
+
+ Actions
+
+
+
+
+ {rows.map((row, index) => {
+ const levels =
+ Array.isArray(row.permissionLevels) && row.permissionLevels.length
+ ? row.permissionLevels
+ : row.permissionLevel
+ ? [
+ {
+ name: row.permissionLevel,
+ isSystemManaged: row.isSystemManaged,
+ roleDefinitionId: row.roleDefinitionId,
+ },
+ ]
+ : []
+ const onlySystem = levels.length > 0 && levels.every((level) => level.isSystemManaged)
+ const isSystemGroup =
+ Boolean(row.isSystemGroup) ||
+ (row.principalId != null && systemIds.has(`${row.principalId}`))
+ const canAct = canWrite && !onlySystem && !isSystemGroup && !!row.principalId
+
+ return (
+
+
+
+
+ {row.title || '—'}
+
+
+
+
+
+ {row.principalType || '—'}
+
+
+
+ {levels.length
+ ? levels.map((level) => (
+ }
+ variant={level.isSystemManaged ? 'outlined' : 'filled'}
+ label={level.name || '—'}
+ title={
+ level.isSystemManaged
+ ? 'System-managed (e.g. Limited Access)'
+ : undefined
+ }
+ />
+ ))
+ : '—'}
+
+
+
+
+ {row.userPrincipalName || row.email || row.loginName || '—'}
+
+
+
+ onEdit(row) : undefined}
+ onRemove={canAct && onRemove ? () => onRemove(row) : undefined}
+ />
+
+
+ )
+ })}
+
+
+
+ )
+}
+
+AccessTable.propTypes = {
+ rows: PropTypes.array,
+ canWrite: PropTypes.bool,
+ systemGroupIds: PropTypes.array,
+ onEdit: PropTypes.func,
+ onRemove: PropTypes.func,
+}
+
+const MembersTable = ({
+ rows = EMPTY,
+ canWrite = false,
+ onRemoveMember,
+ disableRemove = false,
+ disableRemoveTitle = 'Remove unavailable',
+}) => {
+ if (!rows.length) return
+
+ return (
+
+
+
+
+ Name
+ Type
+ Email / UPN
+
+ Actions
+
+
+
+
+ {rows.map((row, index) => {
+ const canRemove =
+ canWrite &&
+ !disableRemove &&
+ typeof onRemoveMember === 'function' &&
+ !!row.principalId
+
+ return (
+
+
+
+
+ {row.title || '—'}
+
+
+
+
+
+ {row.principalType || '—'}
+
+
+
+ {row.userPrincipalName || row.email || row.loginName || '—'}
+
+
+
+ onRemoveMember(row)
+ : undefined
+ }
+ />
+
+
+ )
+ })}
+
+
+
+ )
+}
+
+MembersTable.propTypes = {
+ rows: PropTypes.array,
+ canWrite: PropTypes.bool,
+ onRemoveMember: PropTypes.func,
+ disableRemove: PropTypes.bool,
+ disableRemoveTitle: PropTypes.string,
+}
+
+const GraphSitePermissionsTable = ({ rows = EMPTY, canWrite = false, onRemove }) => {
+ if (!rows.length) {
+ return
+ }
+
+ return (
+
+
+
+
+ Principal
+ Type
+ Roles
+ Id
+
+ Actions
+
+
+
+
+ {rows.map((row, index) => {
+ const canRemove = canWrite && !!row.permissionId && typeof onRemove === 'function'
+ return (
+
+
+
+ {row.title || '—'}
+
+
+
+
+ {row.identityType || '—'}
+
+
+
+
+ {(row.roles ?? []).length
+ ? row.roles.map((role) => (
+ } label={role} />
+ ))
+ : '—'}
+
+
+
+
+ {row.identityId || '—'}
+
+
+
+ onRemove(row) : undefined}
+ />
+
+
+ )
+ })}
+
+
+
+ )
+}
+
+GraphSitePermissionsTable.propTypes = {
+ rows: PropTypes.array,
+ canWrite: PropTypes.bool,
+ onRemove: PropTypes.func,
+}
+
+const SITE_ROOT = '__siteRoot__'
+const SITE_ROOT_OPTION = { label: 'Site root (whole site)', value: SITE_ROOT }
+
+/**
+ * Effective-access check: one user × this site/library, with every route explained.
+ * Lives inline in Permissions (not a stacked dialog). Reuses ListSiteUserAccess.
+ */
+const CheckAccessPanel = ({
+ open,
+ tenantFilter,
+ siteUrl,
+ siteId,
+ defaultListId,
+ defaultListLabel,
+}) => {
+ const defaultScope = useMemo(() => {
+ if (defaultListId) {
+ return {
+ label: defaultListLabel || 'Current library',
+ value: defaultListId,
+ }
+ }
+ return SITE_ROOT_OPTION
+ }, [defaultListId, defaultListLabel])
+
+ const formControl = useForm({
+ defaultValues: { user: null, scope: defaultScope },
+ })
+ const selectedUser = formControl.watch('user')
+ const selectedScope = formControl.watch('scope')
+ const [query, setQuery] = useState(null)
+
+ useEffect(() => {
+ if (!open) {
+ setQuery(null)
+ formControl.reset({ user: null, scope: defaultScope })
+ return
+ }
+ formControl.setValue('scope', defaultScope)
+ }, [open, defaultScope, formControl])
+
+ const libraries = ApiGetCall({
+ url: '/api/ListSiteLibraries',
+ data: { SiteId: siteId, SiteUrl: siteUrl, tenantFilter },
+ queryKey: `SiteLibraries-${siteId ?? siteUrl}`,
+ waiting: open && !!siteUrl,
+ })
+
+ const scopeOptions = useMemo(() => {
+ const libs = Array.isArray(libraries.data?.Results) ? libraries.data.Results : []
+ const fromApi = libs.map((library) => ({
+ label: library.Title,
+ value: library.Id,
+ }))
+ // Keep the current library visible even if ListSiteLibraries is still loading.
+ if (
+ defaultListId &&
+ !fromApi.some((option) => String(option.value) === String(defaultListId))
+ ) {
+ fromApi.unshift({
+ label: defaultListLabel || 'Current library',
+ value: defaultListId,
+ })
+ }
+ return [SITE_ROOT_OPTION, ...fromApi]
+ }, [libraries.data, defaultListId, defaultListLabel])
+
+ const access = ApiGetCall({
+ url: '/api/ListSiteUserAccess',
+ data: query ?? {},
+ queryKey: `SiteUserAccess-${siteUrl}-${query?.ListId || 'root'}-${query?.UserPrincipalName}`,
+ waiting: open && !!query,
+ })
+
+ const runCheck = () => {
+ const upn = optionValue(selectedUser)
+ if (!upn) return
+ const scopeId = optionValue(selectedScope)
+ setQuery({
+ tenantFilter,
+ SiteUrl: siteUrl,
+ ListId: !scopeId || scopeId === SITE_ROOT ? '' : scopeId,
+ UserPrincipalName: upn,
+ })
+ }
+
+ const result = access.data?.Results
+ const data = typeof result === 'object' && result !== null ? result : null
+ const loadError = typeof result === 'string' ? result : null
+ const paths = Array.isArray(data?.Paths) ? data.Paths : EMPTY
+ const realPaths = paths.filter((path) => path.GrantsRealAccess)
+ const limitedOnly = paths.length > 0 && realPaths.length === 0
+
+ return (
+
+
+ Pick a user to see every route that grants them access here — direct grants, SharePoint
+ groups, nested Entra groups, tenant-wide claims, and (when cached) sharing links. This is
+ the inverse of the Access tab: who can reach this place, and how.
+
+
+
+
+ `${user.displayName} (${user.userPrincipalName})`,
+ valueField: 'userPrincipalName',
+ showRefresh: true,
+ }}
+ />
+
+
+
+
+ }
+ disabled={!optionValue(selectedUser) || access.isFetching}
+ onClick={runCheck}
+ sx={{ mt: { md: 1 }, flexShrink: 0 }}
+ >
+ Check
+
+
+
+ {loadError ? {loadError} : null}
+
+ {access.isFetching ? : null}
+
+ {!access.isFetching && data ? (
+
+
+ {data.HasAccess ? (
+
+
+ {data.DisplayName} has access via {data.AccessPathCount}{' '}
+ {data.AccessPathCount === 1 ? 'route' : 'routes'}
+
+ Removing one route does not remove the others — every route below has to go for
+ access to stop.
+
+ ) : (
+
+ {data.DisplayName} has no access
+ {limitedOnly
+ ? 'The only entry found is Limited Access, which SharePoint adds so a user can traverse to a specific item. It does not let them open or list anything here.'
+ : 'No permission, group membership or sharing link grants this user access to this scope.'}
+
+ )}
+
+ {data.LibraryInherits ? (
+
+ This library inherits permissions from the site, so the site's permissions were
+ evaluated.
+
+ ) : null}
+
+
+
+ {data.IsGuest ? (
+
+ ) : null}
+ {!data.SharingLinksChecked ? (
+
+ ) : null}
+
+
+ {!paths.length ? (
+
+ ) : (
+
+
+
+
+ Route
+ Via
+ Permission
+ Applies to
+ Flags
+
+
+
+ {paths.map((path, index) => (
+
+ {path.Route || '—'}
+ {path.Via || '—'}
+ {path.PermissionLevel || '—'}
+ {path.AppliesTo || '—'}
+
+
+ {path.IsSystemManaged ? (
+
+ ) : null}
+ {path.GrantsRealAccess === false ? (
+
+ ) : null}
+
+
+
+ ))}
+
+
+
+ )}
+
+ ) : null}
+
+ )
+}
+
+CheckAccessPanel.propTypes = {
+ open: PropTypes.bool,
+ tenantFilter: PropTypes.string,
+ siteUrl: PropTypes.string,
+ siteId: PropTypes.string,
+ defaultListId: PropTypes.string,
+ defaultListLabel: PropTypes.string,
+}
+
+/**
+ * Permissions dialog for the SharePoint site browser.
+ * Access / Groups / Admins / Apps / Check access.
+ * Sharing links are out of scope (handled elsewhere).
+ */
+export const CippSharePointBrowserPermissions = ({
+ open = false,
+ onClose,
+ item,
+ tenantFilter,
+ siteUrl: siteUrlProp,
+ siteId: siteIdProp,
+}) => {
+ const [tab, setTab] = useState(0)
+ const [selectedGroupKey, setSelectedGroupKey] = useState(null)
+ const { checkPermissions } = usePermissions()
+ const canWrite = checkPermissions(['Sharepoint.Site.ReadWrite'])
+
+ const addUserDialog = useDialog()
+ const addGroupDialog = useDialog()
+ const removeMemberDialog = useDialog()
+ const grantUserDialog = useDialog()
+ const grantGroupDialog = useDialog()
+ const replaceAccessDialog = useDialog()
+ const removeAccessDialog = useDialog()
+ const addAdminDialog = useDialog()
+ const removeAdminDialog = useDialog()
+ const breakInheritanceDialog = useDialog()
+ const restoreInheritanceDialog = useDialog()
+ const removeGraphPermissionDialog = useDialog()
+
+ const isLibrary = item?.type === 'library'
+ const siteUrl = siteUrlProp ?? (isLibrary ? null : item?.webUrl)
+ const siteId = siteIdProp ?? (isLibrary ? null : item?.id)
+ const listId = isLibrary ? item?.id : null
+ const effectiveSiteUrl = siteUrl ?? item?.webUrl
+ const effectiveSiteId = siteId ?? item?.siteId ?? item?.id
+ const permissionsQueryKey = `ListSiteBrowserPermissions-${tenantFilter}-${effectiveSiteUrl}-${listId || 'site'}`
+
+ const api = ApiGetCall({
+ url: '/api/ListSiteBrowserPermissions',
+ data: {
+ tenantFilter,
+ SiteUrl: effectiveSiteUrl,
+ SiteId: effectiveSiteId,
+ ...(listId ? { ListId: listId } : {}),
+ },
+ queryKey: permissionsQueryKey,
+ waiting: open && !!tenantFilter && !!effectiveSiteUrl,
+ })
+
+ const roleDefinitions = ApiGetCall({
+ url: '/api/ListSiteRoleDefinitions',
+ data: { SiteUrl: effectiveSiteUrl, tenantFilter },
+ queryKey: `SiteRoleDefinitions-${effectiveSiteUrl}`,
+ waiting: open && !!tenantFilter && !!effectiveSiteUrl,
+ })
+
+ const result = api.data?.Results
+ const loadError =
+ typeof result === 'string'
+ ? result
+ : api.isError
+ ? (api.error?.message ?? 'Failed to load permissions.')
+ : null
+ const data = typeof result === 'object' && result !== null ? result : null
+
+ const titleName = data?.target?.title || item?.displayName || item?.name || 'Permissions'
+ const targetType = data?.target?.type || (isLibrary ? 'library' : 'site')
+ const inherits = Boolean(data?.target?.inheritsFromSite)
+ const hasUnique = Boolean(data?.target?.hasUniqueRoleAssignments)
+ const canMutateAccess = canWrite && !(targetType === 'library' && inherits)
+ const writeDisabledTitle = !canWrite
+ ? 'Requires SharePoint write permission'
+ : inherits
+ ? 'Break inheritance to change library access'
+ : 'Unavailable'
+
+ const levelOptions = useMemo(() => {
+ const definitions = Array.isArray(roleDefinitions.data?.Results)
+ ? roleDefinitions.data.Results
+ : []
+ return definitions.map((definition) => ({
+ label: definition.IsCustom ? `${definition.Name} (custom)` : definition.Name,
+ value: definition.Id,
+ }))
+ }, [roleDefinitions.data])
+
+ const scopePayload = {
+ tenantFilter,
+ SiteUrl: effectiveSiteUrl,
+ ListId: targetType === 'library' ? listId : '',
+ LibraryName: targetType === 'library' ? titleName : '',
+ }
+
+ const accessRows = useMemo(() => {
+ if (!data) return []
+ if (targetType === 'library' && !inherits) {
+ return data.libraryRoleAssignments ?? []
+ }
+ return data.webRoleAssignments ?? []
+ }, [data, targetType, inherits])
+
+ const systemGroupIds = useMemo(
+ () =>
+ (data?.associatedGroups ?? [])
+ .map((group) => group.groupId)
+ .filter((id) => id !== null && id !== undefined && `${id}`.length),
+ [data]
+ )
+ const groupList = useMemo(() => {
+ if (!data) return []
+ const associated = (data.associatedGroups ?? []).map((group) => ({
+ key: `assoc-${group.role}`,
+ kind: 'associated',
+ label: group.role,
+ subtitle: group.title || '',
+ memberCount: group.memberCount ?? group.members?.length ?? 0,
+ members: group.members ?? [],
+ groupId: group.groupId,
+ isSystemGroup: true,
+ }))
+ const associatedIds = new Set(associated.map((g) => g.groupId).filter(Boolean))
+ const custom = (data.sharePointGroups ?? [])
+ .filter((group) => !associatedIds.has(group.groupId))
+ .map((group) => ({
+ key: `sp-${group.groupId}`,
+ kind: 'sharepoint',
+ label: group.title || group.loginName || group.groupId,
+ subtitle: group.description || 'SharePoint group',
+ memberCount: group.memberCount ?? group.members?.length ?? 0,
+ members: group.members ?? [],
+ groupId: group.groupId,
+ isSystemGroup: Boolean(group.isSystemGroup),
+ }))
+ return [...associated, ...custom]
+ }, [data])
+
+ const activeGroup =
+ groupList.find((group) => group.key === selectedGroupKey) || groupList[0] || null
+ const canNestIntoActiveGroup = canWrite && !!activeGroup?.groupId
+
+ const handleClose = () => {
+ setTab(0)
+ setSelectedGroupKey(null)
+ onClose?.()
+ }
+
+ const removeMember = removeMemberDialog.data
+ const accessRow = replaceAccessDialog.data || removeAccessDialog.data
+ const adminRow = removeAdminDialog.data
+ const graphPermissionRow = removeGraphPermissionDialog.data
+ const graphSitePermissions = data?.graphSitePermissions ?? []
+ const accessScopeLabel =
+ targetType === 'library' && !inherits ? `library ${titleName}` : 'the site'
+
+ return (
+
+
+
+
+ Permissions — {titleName}
+
+
+
+ {inherits ? : null}
+ {hasUnique && targetType === 'library' ? (
+
+ ) : null}
+ {data?.collectedAt ? (
+
+ Collected {new Date(data.collectedAt).toLocaleString()}
+
+ ) : null}
+
+
+
+
+
+ api.refetch()}
+ disabled={!effectiveSiteUrl || api.isFetching}
+ >
+
+
+
+
+
+
+
+
+
+
+ {!effectiveSiteUrl ? (
+ No site URL available for this selection.
+ ) : api.isFetching && !data ? (
+
+
+
+ ) : loadError ? (
+ {loadError}
+ ) : data ? (
+
+ {data.errors?.length ? (
+
+ Some sections failed to load ({data.errors.length}). Showing what was collected.
+
+ ) : null}
+
+ {targetType === 'library' && inherits ? (
+ }
+ disabled={!canWrite}
+ onClick={() => breakInheritanceDialog.handleOpen()}
+ >
+ Stop inheriting
+
+ }
+ >
+ This library inherits permissions from the site. Showing site role assignments;
+ stop inheriting to manage library-specific access.
+
+ ) : null}
+
+ {targetType === 'library' && hasUnique && !inherits ? (
+ }
+ disabled={!canWrite}
+ onClick={() => restoreInheritanceDialog.handleOpen()}
+ >
+ Restore inheritance
+
+ }
+ >
+ This library has unique permissions. Restoring inheritance discards them and
+ follows the site again.
+
+ ) : null}
+
+ setTab(next)}
+ variant="scrollable"
+ allowScrollButtonsMobile
+ >
+
+
+
+
+
+
+
+
+
+ grantUserDialog.handleOpen(),
+ disabled: !canMutateAccess,
+ disabledTitle: writeDisabledTitle,
+ },
+ {
+ label: 'Grant group',
+ onClick: () => grantGroupDialog.handleOpen(),
+ disabled: !canMutateAccess,
+ disabledTitle: writeDisabledTitle,
+ },
+ ]}
+ />
+ replaceAccessDialog.handleOpen(row)}
+ onRemove={(row) => removeAccessDialog.handleOpen(row)}
+ />
+
+
+
+ {!groupList.length ? (
+
+ ) : (
+
+
+
+ Groups
+
+
+ {groupList.map((group) => (
+ setSelectedGroupKey(group.key)}
+ >
+
+
+ ))}
+
+
+
+
+ addUserDialog.handleOpen(),
+ disabled: !canNestIntoActiveGroup,
+ disabledTitle: !canWrite
+ ? 'Requires SharePoint write permission'
+ : !activeGroup?.groupId
+ ? 'Select a SharePoint group'
+ : 'Unavailable',
+ },
+ {
+ label: 'Add group',
+ onClick: () => addGroupDialog.handleOpen(),
+ disabled: !canNestIntoActiveGroup,
+ disabledTitle: !canWrite
+ ? 'Requires SharePoint write permission'
+ : !activeGroup?.groupId
+ ? 'Select a SharePoint group'
+ : 'Unavailable',
+ },
+ ]}
+ />
+ {activeGroup?.subtitle ? (
+
+ {activeGroup.subtitle}
+ {activeGroup.kind === 'associated' ? ' · Associated group' : ''}
+
+ ) : null}
+ removeMemberDialog.handleOpen(row)}
+ />
+
+
+ )}
+
+
+
+ addAdminDialog.handleOpen(),
+ disabled: !canWrite,
+ disabledTitle: 'Requires SharePoint write permission',
+ },
+ ]}
+ />
+ removeAdminDialog.handleOpen(row)}
+ />
+
+ Site collection admins are separate from Owners group membership.
+
+
+
+
+
+ removeGraphPermissionDialog.handleOpen(row)}
+ />
+
+ Site-scoped Graph grants (typically Sites.Selected app access). Separate from
+ SharePoint role assignments and sharing links.
+
+
+
+
+
+
+
+ ) : null}
+
+
+ ({
+ ...scopePayload,
+ Action: 'GrantAccess',
+ RoleDefinitionId: optionValue(formData.RoleDefinitionId),
+ Users: formData.Users ?? [],
+ }),
+ multiPost: false,
+ }}
+ row={item ?? {}}
+ >
+ {({ formHook }) => (
+ <>
+ `${user.displayName} (${user.userPrincipalName})`,
+ valueField: 'userPrincipalName',
+ addedField: { id: 'id' },
+ showRefresh: true,
+ }}
+ />
+
+ >
+ )}
+
+
+ ({
+ ...scopePayload,
+ Action: 'GrantAccess',
+ RoleDefinitionId: optionValue(formData.RoleDefinitionId),
+ Groups: formData.Groups ?? [],
+ }),
+ multiPost: false,
+ }}
+ row={item ?? {}}
+ >
+ {({ formHook }) => (
+ <>
+
+ group.mail ? `${group.displayName} (${group.mail})` : group.displayName,
+ valueField: 'id',
+ addedField: {
+ securityEnabled: 'securityEnabled',
+ groupTypes: 'groupTypes',
+ },
+ showRefresh: true,
+ }}
+ />
+
+ >
+ )}
+
+
+ ({
+ ...scopePayload,
+ Action: 'ReplaceAccess',
+ PrincipalId: accessRow?.principalId,
+ PrincipalName: accessRow?.title,
+ RoleDefinitionId: optionValue(formData.RoleDefinitionId),
+ }),
+ multiPost: false,
+ }}
+ row={accessRow ?? {}}
+ >
+ {({ formHook }) => (
+
+ )}
+
+
+ ({
+ ...scopePayload,
+ Action: 'RemoveAccess',
+ PrincipalId: accessRow?.principalId,
+ PrincipalName: accessRow?.title,
+ }),
+ multiPost: false,
+ }}
+ row={accessRow ?? {}}
+ />
+
+ ({
+ tenantFilter,
+ SiteUrl: effectiveSiteUrl,
+ Action: 'AddGroupMember',
+ GroupId: activeGroup?.groupId,
+ GroupName: activeGroup?.subtitle || activeGroup?.label,
+ Users: formData.Users ?? [],
+ }),
+ multiPost: false,
+ }}
+ row={activeGroup ?? {}}
+ >
+ {({ formHook }) => (
+ `${user.displayName} (${user.userPrincipalName})`,
+ valueField: 'userPrincipalName',
+ addedField: { id: 'id' },
+ showRefresh: true,
+ }}
+ />
+ )}
+
+
+ ({
+ tenantFilter,
+ SiteUrl: effectiveSiteUrl,
+ Action: 'AddGroupMember',
+ GroupId: activeGroup?.groupId,
+ GroupName: activeGroup?.subtitle || activeGroup?.label,
+ Groups: formData.Groups ?? [],
+ }),
+ multiPost: false,
+ }}
+ row={activeGroup ?? {}}
+ >
+ {({ formHook }) => (
+
+ group.mail ? `${group.displayName} (${group.mail})` : group.displayName,
+ valueField: 'id',
+ addedField: {
+ securityEnabled: 'securityEnabled',
+ groupTypes: 'groupTypes',
+ },
+ showRefresh: true,
+ }}
+ />
+ )}
+
+
+ ({
+ tenantFilter,
+ SiteUrl: effectiveSiteUrl,
+ Action: 'RemoveGroupMember',
+ GroupId: activeGroup?.groupId,
+ GroupName: activeGroup?.subtitle || activeGroup?.label,
+ PrincipalId: removeMember?.principalId,
+ PrincipalName: removeMember?.title,
+ }),
+ multiPost: false,
+ }}
+ row={removeMember ?? {}}
+ />
+
+ ({
+ tenantFilter,
+ SiteUrl: effectiveSiteUrl,
+ Action: 'AddSiteAdmin',
+ Users: formData.Users ?? [],
+ }),
+ multiPost: false,
+ }}
+ row={item ?? {}}
+ >
+ {({ formHook }) => (
+ `${user.displayName} (${user.userPrincipalName})`,
+ valueField: 'userPrincipalName',
+ addedField: { id: 'id' },
+ showRefresh: true,
+ }}
+ />
+ )}
+
+
+ ({
+ tenantFilter,
+ SiteUrl: effectiveSiteUrl,
+ Action: 'RemoveSiteAdmin',
+ Users: [
+ {
+ value: adminRow?.userPrincipalName || adminRow?.email || adminRow?.title,
+ label: adminRow?.title,
+ },
+ ],
+ PrincipalName: adminRow?.title,
+ userPrincipalName: adminRow?.userPrincipalName,
+ }),
+ multiPost: false,
+ }}
+ row={adminRow ?? {}}
+ />
+
+ ({
+ ...scopePayload,
+ Action: 'BreakInheritance',
+ CopyRoleAssignments: formData.CopyRoleAssignments !== false,
+ ClearSubscopes: formData.ClearSubscopes === true,
+ }),
+ multiPost: false,
+ }}
+ row={item ?? {}}
+ >
+ {({ formHook }) => (
+ <>
+
+
+ Turn this off to start from an empty permission set. Only site collection admins can
+ reach the library until permissions are granted.
+
+
+ >
+ )}
+
+
+ ({
+ ...scopePayload,
+ Action: 'RestoreInheritance',
+ }),
+ multiPost: false,
+ }}
+ row={item ?? {}}
+ />
+
+ ({
+ tenantFilter,
+ SiteUrl: effectiveSiteUrl,
+ SiteId: data?.target?.siteId || effectiveSiteId,
+ Action: 'RemoveGraphSitePermission',
+ PermissionId: graphPermissionRow?.permissionId,
+ PrincipalName: graphPermissionRow?.title || graphPermissionRow?.identityId,
+ }),
+ multiPost: false,
+ }}
+ row={graphPermissionRow ?? {}}
+ />
+
+ )
+}
+
+CippSharePointBrowserPermissions.propTypes = {
+ open: PropTypes.bool,
+ onClose: PropTypes.func,
+ item: PropTypes.object,
+ tenantFilter: PropTypes.string,
+ siteUrl: PropTypes.string,
+ siteId: PropTypes.string,
+}
diff --git a/src/components/CippComponents/CippSharePointBrowserProperties.jsx b/src/components/CippComponents/CippSharePointBrowserProperties.jsx
new file mode 100644
index 000000000000..128652107ff5
--- /dev/null
+++ b/src/components/CippComponents/CippSharePointBrowserProperties.jsx
@@ -0,0 +1,184 @@
+import { useEffect } from 'react'
+import PropTypes from 'prop-types'
+import { Card, CardHeader, Typography } from '@mui/material'
+import { CippPropertyList } from './CippPropertyList'
+import { CippCopyToClipBoard } from './CippCopyToClipboard'
+import { ApiPostCall } from '../../api/ApiCall'
+
+const isSiteLike = (item) => item && (item.type === 'site' || item.canOpen)
+
+const formatVersionPolicy = (props) => {
+ if (!props || typeof props !== 'object') return null
+ if (props.InheritVersionPolicyFromTenant) {
+ return 'Tenant default'
+ }
+ const major =
+ props.MajorVersionLimit === null || props.MajorVersionLimit === undefined
+ ? null
+ : Number(props.MajorVersionLimit)
+ const days =
+ props.ExpireVersionsAfterDays === null || props.ExpireVersionsAfterDays === undefined
+ ? null
+ : Number(props.ExpireVersionsAfterDays)
+
+ if (props.EnableAutoExpirationVersionTrim) {
+ const parts = ['Auto trim']
+ if (major !== null && !Number.isNaN(major) && major > 0) {
+ parts.push(`${major.toLocaleString()} major`)
+ }
+ if (days !== null && !Number.isNaN(days) && days > 0) {
+ parts.push(`${days.toLocaleString()} days`)
+ }
+ return parts.join(' · ')
+ }
+
+ if (major !== null && !Number.isNaN(major)) {
+ if (major <= 0) return 'Unlimited / not set'
+ const label = `${major.toLocaleString()} major versions`
+ if (days !== null && !Number.isNaN(days) && days > 0) {
+ return `${label} · expire after ${days.toLocaleString()} days`
+ }
+ return label
+ }
+
+ return '—'
+}
+
+/**
+ * Left-hand property panel for the selected SharePoint site or library.
+ * List columns cover type / name / files / size — this pane keeps IDs, URL, and site version policy.
+ */
+export const CippSharePointBrowserProperties = ({
+ item,
+ tenantFilter,
+ isFetching = false,
+ emptyMessage = 'Select an item to view details.',
+}) => {
+ const siteUrl = isSiteLike(item) ? item.webUrl : null
+ const siteId = isSiteLike(item) ? item.id : null
+ const sitePropsApi = ApiPostCall({})
+
+ useEffect(() => {
+ if (!tenantFilter || (!siteUrl && !siteId)) return
+ sitePropsApi.mutate({
+ url: '/api/ExecSiteBrowserActions',
+ data: {
+ Action: 'GetSiteProperties',
+ tenantFilter,
+ SiteUrl: siteUrl,
+ SiteId: siteId,
+ },
+ })
+ // refetch when the selected site changes
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [tenantFilter, siteUrl, siteId])
+
+ const rawSiteProps = sitePropsApi.data?.data?.Results
+ const normalizedSiteUrl = siteUrl ? siteUrl.replace(/\/+$/, '') : null
+ const siteAdminProps =
+ typeof rawSiteProps === 'object' &&
+ rawSiteProps !== null &&
+ !Array.isArray(rawSiteProps) &&
+ (!normalizedSiteUrl ||
+ !rawSiteProps.Url ||
+ String(rawSiteProps.Url).replace(/\/+$/, '') === normalizedSiteUrl)
+ ? rawSiteProps
+ : null
+ const versionsLabel = formatVersionPolicy(siteAdminProps)
+ const versionsFetching = Boolean(
+ (siteUrl || siteId) && (sitePropsApi.isPending || (!siteAdminProps && !sitePropsApi.isError))
+ )
+
+ const propertyItems = (() => {
+ if (!item) return []
+
+ if (isSiteLike(item)) {
+ return [
+ {
+ label: 'Description',
+ value: item.description?.trim() ? item.description : '—',
+ },
+ {
+ label: 'Versions',
+ value: versionsFetching ? '' : versionsLabel || '—',
+ },
+ {
+ label: 'Site ID',
+ value: item.siteId ? : '—',
+ },
+ {
+ label: 'Graph ID',
+ value: item.id ? : '—',
+ },
+ {
+ label: 'Web ID',
+ value: item.webId ? : '—',
+ },
+ {
+ label: 'URL',
+ value: item.webUrl ? : '—',
+ },
+ ]
+ }
+
+ return [
+ { label: 'Template', value: item.template || '—' },
+ {
+ label: 'List ID',
+ value: item.id ? : '—',
+ },
+ {
+ label: 'Site ID',
+ value: item.siteId ? : '—',
+ },
+ {
+ label: 'URL',
+ value: item.webUrl ? : '—',
+ },
+ ]
+ })()
+
+ return (
+
+
+ {!item && !isFetching ? (
+
+ {emptyMessage}
+
+ ) : (
+
+ )}
+
+ )
+}
+
+CippSharePointBrowserProperties.propTypes = {
+ item: PropTypes.object,
+ tenantFilter: PropTypes.string,
+ isFetching: PropTypes.bool,
+ emptyMessage: PropTypes.string,
+}
diff --git a/src/components/CippComponents/CippSharePointBrowserStorage.jsx b/src/components/CippComponents/CippSharePointBrowserStorage.jsx
new file mode 100644
index 000000000000..a054dd438c64
--- /dev/null
+++ b/src/components/CippComponents/CippSharePointBrowserStorage.jsx
@@ -0,0 +1,747 @@
+import { useEffect, useMemo, useState } from 'react'
+import PropTypes from 'prop-types'
+import {
+ Alert,
+ Box,
+ Button,
+ Chip,
+ CircularProgress,
+ Dialog,
+ DialogContent,
+ DialogTitle,
+ Divider,
+ IconButton,
+ LinearProgress,
+ Stack,
+ Tab,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Tabs,
+ Tooltip,
+ Typography,
+} from '@mui/material'
+import {
+ CleaningServices,
+ Close,
+ Refresh,
+ RestoreFromTrash,
+ Storage as StorageIcon,
+} from '@mui/icons-material'
+import { CippDataTable } from '../CippTable/CippDataTable'
+import { CippApiDialog } from './CippApiDialog'
+import CippFormComponent from './CippFormComponent'
+import { CippFormCondition } from './CippFormCondition'
+import { CippPropertyList } from './CippPropertyList'
+import { ApiGetCall, ApiPostCall } from '../../api/ApiCall'
+import { useDialog } from '../../hooks/use-dialog'
+import { usePermissions } from '../../hooks/use-permissions'
+
+const optionValue = (value) =>
+ value && typeof value === 'object' && 'value' in value ? value.value : value
+
+const TabPanel = ({ value, index, children }) =>
+ value === index ? {children} : null
+
+TabPanel.propTypes = {
+ value: PropTypes.number.isRequired,
+ index: PropTypes.number.isRequired,
+ children: PropTypes.node,
+}
+
+const VERSION_CLEANUP_LABELS = {
+ Status: 'Status',
+ BatchDeleteMode: 'Cleanup Mode',
+ RequestTimeInUTC: 'Requested (UTC)',
+ LastProcessTimeInUTC: 'Last Processed (UTC)',
+ CompleteTimeInUTC: 'Completed (UTC)',
+ ListsProcessed: 'Lists Processed',
+ ListsUpdated: 'Lists Updated',
+ ListsFailed: 'Lists Failed',
+ FilesProcessed: 'Files Processed',
+ VersionsProcessed: 'Versions Processed',
+ VersionsDeleted: 'Versions Deleted',
+ VersionsFailed: 'Versions Failed',
+ StorageReleased: 'Storage Released (bytes)',
+ ErrorMessage: 'Error Message',
+ WorkItemId: 'Work Item ID',
+ Message: 'Message',
+}
+const VERSION_CLEANUP_FIELDS = Object.keys(VERSION_CLEANUP_LABELS)
+const TOP_LIBRARIES = 8
+
+const formatBytes = (bytes) => {
+ const num = Number(bytes)
+ if (bytes === null || bytes === undefined || bytes === '' || Number.isNaN(num)) return null
+ if (num < 1024) return `${num} B`
+ const gb = num / (1024 * 1024 * 1024)
+ if (gb >= 0.01) return `${gb.toLocaleString(undefined, { maximumFractionDigits: 2 })} GB`
+ const mb = num / (1024 * 1024)
+ return `${mb.toLocaleString(undefined, { maximumFractionDigits: 2 })} MB`
+}
+
+const toBytesFromMb = (mb) => {
+ if (mb === null || mb === undefined || mb === '') return null
+ const num = Number(mb)
+ if (Number.isNaN(num)) return null
+ return num * 1024 * 1024
+}
+
+const formatVersionPolicy = (props) => {
+ if (!props || typeof props !== 'object') return null
+ if (props.InheritVersionPolicyFromTenant) return 'Tenant default'
+ const major =
+ props.MajorVersionLimit === null || props.MajorVersionLimit === undefined
+ ? null
+ : Number(props.MajorVersionLimit)
+ const days =
+ props.ExpireVersionsAfterDays === null || props.ExpireVersionsAfterDays === undefined
+ ? null
+ : Number(props.ExpireVersionsAfterDays)
+
+ if (props.EnableAutoExpirationVersionTrim) {
+ const parts = ['Auto trim']
+ if (major !== null && !Number.isNaN(major) && major > 0) {
+ parts.push(`${major.toLocaleString()} major`)
+ }
+ if (days !== null && !Number.isNaN(days) && days > 0) {
+ parts.push(`${days.toLocaleString()} days`)
+ }
+ return parts.join(' · ')
+ }
+
+ if (major !== null && !Number.isNaN(major)) {
+ if (major <= 0) return 'Unlimited / not set'
+ const label = `${major.toLocaleString()} major versions`
+ if (days !== null && !Number.isNaN(days) && days > 0) {
+ return `${label} · expire after ${days.toLocaleString()} days`
+ }
+ return label
+ }
+ return null
+}
+
+const jobStatusChip = (progress) => {
+ if (!progress || typeof progress === 'string') {
+ return { label: 'No job', color: 'default' }
+ }
+ if (progress.Status === 'NoRequestFound' || progress.Status === 'NoJob') {
+ return { label: 'No job', color: 'default' }
+ }
+ const status = String(progress.Status ?? '').toLowerCase()
+ if (!status) return { label: 'Unknown', color: 'default' }
+ if (status.includes('complete') || status.includes('success')) {
+ return { label: progress.Status, color: 'success' }
+ }
+ if (status.includes('fail') || status.includes('error')) {
+ return { label: progress.Status, color: 'error' }
+ }
+ if (status.includes('run') || status.includes('progress') || status.includes('pending')) {
+ return { label: progress.Status, color: 'warning' }
+ }
+ return { label: progress.Status, color: 'info' }
+}
+
+const VersionCleanupFields = ({ formHook }) => (
+ <>
+
+
+
+
+
+
+
+
+ >
+)
+
+VersionCleanupFields.propTypes = {
+ formHook: PropTypes.object.isRequired,
+}
+
+/**
+ * Site-scoped Storage sheet for cleanup.
+ * Overview (cheap live): used/quota, version policy, top libraries.
+ * Recycle / Versions tabs: cleanup actions — no file-level scans.
+ */
+export const CippSharePointBrowserStorage = ({
+ open = false,
+ onClose,
+ item,
+ tenantFilter,
+}) => {
+ const [tab, setTab] = useState(0)
+ const { checkPermissions } = usePermissions()
+ const canWriteSite = checkPermissions(['Sharepoint.Site.ReadWrite'])
+ const canReadRecycleBin = checkPermissions([
+ 'Sharepoint.SiteRecycleBin.Read',
+ 'Sharepoint.SiteRecycleBin.ReadWrite',
+ ])
+ const canRestore = checkPermissions(['Sharepoint.SiteRecycleBin.ReadWrite'])
+ const startCleanupDialog = useDialog()
+
+ const siteUrl = item?.webUrl
+ const siteId = item?.id
+ const siteName = item?.displayName || item?.name || 'Site'
+ const tenant = item?.Tenant ?? tenantFilter
+ const sitePropsApi = ApiPostCall({})
+ const jobStatusApi = ApiPostCall({})
+
+ const librariesApi = ApiGetCall({
+ url: '/api/ListSiteBrowser',
+ data: {
+ tenantFilter: tenant,
+ SiteId: siteId,
+ SiteUrl: siteUrl,
+ },
+ queryKey: `SiteBrowserStorageLibs-${tenant}-${siteId || siteUrl}`,
+ waiting: open && !!tenant && !!(siteId || siteUrl),
+ })
+
+ const fetchSiteProps = () => {
+ if (!tenant || (!siteUrl && !siteId)) return
+ sitePropsApi.mutate({
+ url: '/api/ExecSiteBrowserActions',
+ data: {
+ Action: 'GetSiteProperties',
+ tenantFilter: tenant,
+ SiteUrl: siteUrl,
+ SiteId: siteId,
+ },
+ })
+ }
+
+ const fetchJobStatus = () => {
+ if (!tenant || (!siteUrl && !siteId)) return
+ jobStatusApi.mutate({
+ url: '/api/ExecSiteBrowserActions',
+ data: {
+ Action: 'GetVersionCleanupStatus',
+ tenantFilter: tenant,
+ SiteUrl: siteUrl,
+ SiteId: siteId,
+ },
+ })
+ }
+
+ const refreshAll = () => {
+ fetchSiteProps()
+ librariesApi.refetch?.()
+ if (tab === 2) fetchJobStatus()
+ }
+
+ useEffect(() => {
+ if (!open) return
+ setTab(0)
+ fetchSiteProps()
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [open, siteUrl, siteId, tenant])
+
+ useEffect(() => {
+ if (!open || tab !== 2) return
+ fetchJobStatus()
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [open, tab, siteUrl, siteId, tenant])
+
+ const siteProps =
+ typeof sitePropsApi.data?.data?.Results === 'object' &&
+ sitePropsApi.data?.data?.Results !== null &&
+ !Array.isArray(sitePropsApi.data?.data?.Results)
+ ? sitePropsApi.data.data.Results
+ : null
+
+ const jobProgress = jobStatusApi.data?.data?.Results
+ const versionsLabel = formatVersionPolicy(siteProps)
+ const chip = useMemo(() => jobStatusChip(jobProgress), [jobProgress])
+
+ const usedBytes = useMemo(() => {
+ const fromItem = Number(item?.storageUsedInBytes)
+ if (!Number.isNaN(fromItem) && fromItem > 0) return fromItem
+ return toBytesFromMb(siteProps?.StorageUsage)
+ }, [item?.storageUsedInBytes, siteProps?.StorageUsage])
+
+ const quotaBytes = toBytesFromMb(siteProps?.StorageMaximumLevel)
+ const warningBytes = toBytesFromMb(siteProps?.StorageWarningLevel)
+ const usedLabel = formatBytes(usedBytes) || '—'
+ const quotaLabel = formatBytes(quotaBytes)
+ const usedPct =
+ quotaBytes && usedBytes !== null && quotaBytes > 0
+ ? Math.min(100, Math.round((usedBytes / quotaBytes) * 1000) / 10)
+ : null
+ const nearWarning =
+ warningBytes && usedBytes !== null ? usedBytes >= warningBytes : usedPct !== null && usedPct >= 85
+ const quotaBarColor = nearWarning ? 'warning' : 'primary'
+
+ const libraryRows = useMemo(() => {
+ const raw = librariesApi.data?.Results
+ if (!Array.isArray(raw)) return []
+ return [...raw]
+ .map((lib) => ({
+ ...lib,
+ _bytes: Number(lib.storageUsedInBytes),
+ }))
+ .sort((a, b) => {
+ const aOk = !Number.isNaN(a._bytes) ? a._bytes : -1
+ const bOk = !Number.isNaN(b._bytes) ? b._bytes : -1
+ return bOk - aOk
+ })
+ }, [librariesApi.data])
+
+ const topLibraries = libraryRows.slice(0, TOP_LIBRARIES)
+ const librariesMeasuredBytes = useMemo(
+ () =>
+ libraryRows.reduce((sum, lib) => {
+ if (Number.isNaN(lib._bytes) || lib._bytes < 0) return sum
+ return sum + lib._bytes
+ }, 0),
+ [libraryRows]
+ )
+ const librariesMeasuredLabel = formatBytes(librariesMeasuredBytes)
+ const maxLibBytes = topLibraries[0]?._bytes > 0 ? topLibraries[0]._bytes : 0
+
+ const glanceLoading = sitePropsApi.isPending && !siteProps
+ const libsLoading = librariesApi.isFetching && !libraryRows.length
+
+ const handleClose = () => {
+ setTab(0)
+ onClose?.()
+ }
+
+ const recycleBinQueryKey = `SiteBrowserRecycleBin-${siteUrl}`
+
+ const recycleActions = [
+ {
+ label: 'Restore Item',
+ type: 'POST',
+ icon: ,
+ url: '/api/ExecRestoreRecycleBinItems',
+ data: {
+ Ids: 'Id',
+ ItemNames: 'LeafName',
+ SiteUrl: siteUrl,
+ tenantFilter: tenant,
+ },
+ confirmText: 'Restore [LeafName] from the recycle bin?',
+ condition: () => canRestore,
+ multiPost: false,
+ },
+ ]
+
+ return (
+ <>
+
+
+
+ Storage — {siteName}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {!siteUrl ? (
+ No site URL available for this selection.
+ ) : (
+
+ setTab(next)}
+ variant="scrollable"
+ allowScrollButtonsMobile
+ >
+
+
+
+
+
+
+
+
+ {glanceLoading ? (
+
+
+
+ ) : (
+
+
+ }
+ color={nearWarning ? 'warning' : 'default'}
+ label={
+ quotaLabel
+ ? `Used ${usedLabel} / ${quotaLabel}${
+ usedPct !== null ? ` (${usedPct}%)` : ''
+ }`
+ : `Used ${usedLabel}`
+ }
+ />
+
+ {librariesMeasuredLabel ? (
+
+ ) : null}
+
+
+ {quotaBytes ? (
+
+
+
+ {nearWarning
+ ? 'Near quota warning — reclaim recycle or trim versions before the site locks writes.'
+ : 'Quota usage from site properties (live).'}
+
+
+ ) : null}
+
+
+ Cleanup path: check largest libraries → Recycle bin
+ (1st/2nd stage) → Versions if history looks like the gap. Version bytes are
+ not measured live (that would scan files).
+
+
+ )}
+
+
+
+ Largest libraries
+ {libsLoading ? : null}
+
+ {librariesApi.isError ? (
+
+ Could not load library sizes. You can still use Recycle and Versions.
+
+ ) : !libsLoading && !topLibraries.length ? (
+
+ No document libraries returned for this site.
+
+ ) : (
+
+
+
+
+ Library
+ Type
+ Files
+
+ Size
+
+
+
+
+ {topLibraries.map((lib) => {
+ const pct =
+ maxLibBytes > 0 && !Number.isNaN(lib._bytes) && lib._bytes > 0
+ ? Math.min(100, (lib._bytes / maxLibBytes) * 100)
+ : 0
+ return (
+
+
+
+ {lib.displayName || lib.name || '—'}
+
+
+
+
+ {lib.siteType || '—'}
+
+
+
+
+ {lib.fileCount != null
+ ? Number(lib.fileCount).toLocaleString()
+ : '—'}
+
+
+
+
+
+ {formatBytes(lib.storageUsedInBytes) || '—'}
+
+ {pct > 0 ? (
+
+ ) : null}
+
+
+
+ )
+ })}
+
+
+
+ )}
+
+ Library size = root folder StorageMetrics (live). Site used may be higher —
+ recycle, versions, and other lists are not in this table.
+ {libraryRows.length > TOP_LIBRARIES
+ ? ` Showing top ${TOP_LIBRARIES} of ${libraryRows.length}.`
+ : ''}
+
+
+
+
+
+
+ {!canReadRecycleBin ? (
+
+ Recycle bin requires SharePoint recycle bin read permission.
+
+ ) : (
+ <>
+
+ First and second stage together (newest first, capped by the API). Filter on
+ Item State. Sizes are per item — totals are not fully summed live.
+
+
+ >
+ )}
+
+
+
+
+
+ Version history trim
+
+
+
+ }
+ onClick={fetchJobStatus}
+ disabled={jobStatusApi.isPending}
+ >
+ Refresh status
+
+
+
+ }
+ disabled={!canWriteSite}
+ onClick={() => startCleanupDialog.handleOpen()}
+ >
+ Start cleanup
+
+
+
+
+
+
+
+ Site policy: {versionsLabel || '—'}. A cleanup job trims existing file versions; it
+ does not change the policy. Use when libraries look smaller than site used and
+ recycle is already thin — classic version bloat.
+
+
+ {jobStatusApi.isError ? (
+
+ {typeof jobStatusApi.error?.response?.data?.Results === 'string'
+ ? jobStatusApi.error.response.data.Results
+ : 'Failed to load cleanup job status.'}
+
+ ) : null}
+
+ {jobStatusApi.isPending && !jobProgress ? (
+
+
+
+ ) : !jobProgress ||
+ (typeof jobProgress === 'string' && !jobProgress.trim()) ||
+ jobProgress?.Status === 'NoRequestFound' ||
+ jobProgress?.Status === 'NoJob' ? (
+
+ {jobProgress?.Message || 'No cleanup job found for this site.'}
+
+ ) : typeof jobProgress === 'string' ? (
+ {jobProgress}
+ ) : (
+ jobProgress?.[key] !== undefined && jobProgress?.[key] !== ''
+ ).map((key) => ({
+ label: VERSION_CLEANUP_LABELS[key],
+ value: String(jobProgress[key]),
+ }))}
+ />
+ )}
+
+
+ )}
+
+
+
+ {
+ const mode = parseInt(optionValue(formData.BatchDeleteMode) ?? '2', 10)
+ return {
+ tenantFilter: tenant,
+ SiteUrl: siteUrl,
+ SiteId: siteId,
+ Action: 'StartVersionCleanup',
+ BatchDeleteMode: mode,
+ DeleteOlderThanDays: mode === 0 ? parseInt(formData.DeleteOlderThanDays, 10) : -1,
+ MajorVersionLimit: mode === 1 ? parseInt(formData.MajorVersionLimit, 10) : -1,
+ MajorWithMinorVersionsLimit:
+ mode === 1 ? parseInt(formData.MajorWithMinorVersionsLimit, 10) : -1,
+ }
+ },
+ multiPost: false,
+ onSuccess: () => {
+ fetchJobStatus()
+ },
+ }}
+ row={item ?? {}}
+ >
+ {({ formHook }) => }
+
+ >
+ )
+}
+
+CippSharePointBrowserStorage.propTypes = {
+ open: PropTypes.bool,
+ onClose: PropTypes.func,
+ item: PropTypes.object,
+ tenantFilter: PropTypes.string,
+}
diff --git a/src/components/CippComponents/CippSharePointFolderView.jsx b/src/components/CippComponents/CippSharePointFolderView.jsx
new file mode 100644
index 000000000000..5c02606a3eb9
--- /dev/null
+++ b/src/components/CippComponents/CippSharePointFolderView.jsx
@@ -0,0 +1,918 @@
+import { useEffect, useMemo, useState } from 'react'
+import PropTypes from 'prop-types'
+import {
+ Alert,
+ Badge,
+ Box,
+ Breadcrumbs,
+ Button,
+ Card,
+ Checkbox,
+ Chip,
+ CircularProgress,
+ Divider,
+ FormControlLabel,
+ IconButton,
+ InputAdornment,
+ Link,
+ ListItemIcon,
+ ListItemText,
+ Menu,
+ MenuItem,
+ Popover,
+ Radio,
+ RadioGroup,
+ Stack,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ TableSortLabel,
+ TextField,
+ Tooltip,
+ Typography,
+} from '@mui/material'
+import { alpha } from '@mui/material/styles'
+import {
+ ArrowUpward,
+ Clear,
+ FilterList,
+ Folder,
+ FolderOpen,
+ FolderShared,
+ MoreVert,
+ OpenInNew,
+ Search as SearchIcon,
+} from '@mui/icons-material'
+
+const formatDate = (value) => {
+ if (!value) return '—'
+ const date = new Date(value)
+ if (Number.isNaN(date.getTime()) || date.getUTCFullYear() <= 1) return '—'
+ return date.toLocaleString(undefined, {
+ year: 'numeric',
+ month: 'numeric',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ })
+}
+
+const formatSizeGb = (bytes) => {
+ if (bytes === null || bytes === undefined || bytes === '') return null
+ const num = Number(bytes)
+ if (Number.isNaN(num)) return null
+ return num / (1024 * 1024 * 1024)
+}
+
+const formatSizeMb = (bytes) => {
+ if (bytes === null || bytes === undefined || bytes === '') return null
+ const num = Number(bytes)
+ if (Number.isNaN(num)) return null
+ return num / (1024 * 1024)
+}
+
+const formatSizeGbLabel = (bytes) => {
+ const gb = formatSizeGb(bytes)
+ if (gb === null) return '—'
+ return gb.toLocaleString(undefined, { maximumFractionDigits: 2 })
+}
+
+const formatSizeMbTooltip = (bytes) => {
+ const mb = formatSizeMb(bytes)
+ if (mb === null) return null
+ return `${mb.toLocaleString(undefined, { maximumFractionDigits: 2 })} MB`
+}
+
+const RowActionsMenu = ({ item, actions = [] }) => {
+ const [anchorEl, setAnchorEl] = useState(null)
+ const open = Boolean(anchorEl)
+ const available = actions.filter((action) => {
+ if (typeof action.condition === 'function') return action.condition(item)
+ return true
+ })
+
+ if (!available.length) return null
+
+ return (
+ <>
+ {
+ event.stopPropagation()
+ setAnchorEl(event.currentTarget)
+ }}
+ >
+
+
+ setAnchorEl(null)}
+ onClick={(event) => event.stopPropagation()}
+ anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
+ transformOrigin={{ horizontal: 'right', vertical: 'top' }}
+ >
+ {available.map((action) => (
+ {
+ setAnchorEl(null)
+ action.onClick?.(item)
+ }}
+ component={action.href ? 'a' : 'li'}
+ href={action.href?.(item)}
+ target={action.href ? '_blank' : undefined}
+ rel={action.href ? 'noopener noreferrer' : undefined}
+ >
+ {action.icon ? {action.icon} : null}
+ {action.label}
+
+ ))}
+
+ >
+ )
+}
+
+RowActionsMenu.propTypes = {
+ item: PropTypes.object.isRequired,
+ actions: PropTypes.array,
+}
+
+const formatFileCount = (value) => {
+ if (value === null || value === undefined || value === '') return '—'
+ const num = Number(value)
+ if (Number.isNaN(num)) return '—'
+ return num.toLocaleString()
+}
+
+const COLUMNS = [
+ { id: 'name', label: 'Name', align: 'left', width: undefined, defaultDir: 'asc' },
+ { id: 'webUrl', label: 'URL', align: 'center', width: 72, defaultDir: 'asc' },
+ { id: 'siteType', label: 'Type', align: 'left', width: '14%', defaultDir: 'asc' },
+ { id: 'fileCount', label: 'Files', align: 'right', width: '10%', defaultDir: 'desc' },
+ { id: 'size', label: 'Size (GB)', align: 'right', width: '10%', defaultDir: 'desc' },
+ { id: 'created', label: 'Created', align: 'left', width: '16%', defaultDir: 'desc' },
+]
+
+const getSortValue = (item, columnId) => {
+ switch (columnId) {
+ case 'name':
+ return (item.displayName ?? item.name ?? '').toString().toLocaleLowerCase()
+ case 'webUrl':
+ return (item.webUrl ?? '').toString().toLocaleLowerCase()
+ case 'siteType':
+ return (item.siteType ?? '').toString().toLocaleLowerCase()
+ case 'fileCount': {
+ const num = Number(item.fileCount)
+ return Number.isFinite(num) ? num : null
+ }
+ case 'size': {
+ const num = Number(item.storageUsedInBytes)
+ return Number.isFinite(num) ? num : null
+ }
+ case 'created': {
+ const time = item.createdDateTime ? Date.parse(item.createdDateTime) : NaN
+ return Number.isFinite(time) ? time : null
+ }
+ default:
+ return null
+ }
+}
+
+const compareItems = (a, b, columnId, direction) => {
+ const aVal = getSortValue(a, columnId)
+ const bVal = getSortValue(b, columnId)
+ const aEmpty = aVal === null || aVal === undefined || aVal === ''
+ const bEmpty = bVal === null || bVal === undefined || bVal === ''
+
+ if (aEmpty && bEmpty) return 0
+ if (aEmpty) return 1
+ if (bEmpty) return -1
+
+ let result
+ if (typeof aVal === 'number' && typeof bVal === 'number') {
+ result = aVal - bVal
+ } else {
+ result = String(aVal).localeCompare(String(bVal), undefined, { sensitivity: 'base' })
+ }
+
+ return direction === 'asc' ? result : -result
+}
+
+const itemSearchText = (item) =>
+ [item?.displayName, item?.name, item?.webUrl, item?.siteType, item?.type]
+ .filter(Boolean)
+ .join(' ')
+ .toLowerCase()
+
+const matchesSearch = (item, query) => {
+ const q = query.trim().toLowerCase()
+ if (!q) return true
+ return itemSearchText(item).includes(q)
+}
+
+const GB = 1024 * 1024 * 1024
+const SIZE_FILTERS = [
+ { label: 'Any size', value: 0 },
+ { label: 'Over 1 GB', value: 1 * GB },
+ { label: 'Over 10 GB', value: 10 * GB },
+ { label: 'Over 50 GB', value: 50 * GB },
+ { label: 'Over 100 GB', value: 100 * GB },
+]
+
+const typeLabel = (item) => {
+ const label = (item?.siteType ?? '').toString().trim()
+ return label || 'Unknown'
+}
+
+const matchesFilters = (item, { types, minSizeBytes }) => {
+ if (types.length > 0 && !types.includes(typeLabel(item))) return false
+ if (minSizeBytes > 0) {
+ const bytes = Number(item?.storageUsedInBytes)
+ if (!Number.isFinite(bytes) || bytes < minSizeBytes) return false
+ }
+ return true
+}
+
+const sizeFilterLabel = (minSizeBytes) =>
+ SIZE_FILTERS.find((option) => option.value === minSizeBytes)?.label ?? 'Any size'
+
+/**
+ * Explorer-style details list for the SharePoint site browser.
+ * Columns: Name, URL, Type, Files, Size (GB), Created.
+ * Click selects; double-click / Enter opens when canOpen is true.
+ */
+export const CippSharePointFolderView = ({
+ items = [],
+ isFetching = false,
+ error,
+ path = [],
+ onNavigate,
+ onSelect,
+ checkedIds = [],
+ onCheckedChange,
+ onOpen,
+ rowActions = [],
+ emptyMessage = 'No items found.',
+}) => {
+ const [sortBy, setSortBy] = useState('name')
+ const [sortDir, setSortDir] = useState('asc')
+ const [searchQuery, setSearchQuery] = useState('')
+ const [filterTypes, setFilterTypes] = useState([])
+ const [minSizeBytes, setMinSizeBytes] = useState(0)
+ const [filterAnchor, setFilterAnchor] = useState(null)
+
+ const pathKey = path.map((crumb) => crumb?.id ?? crumb?.webUrl ?? '').join('/')
+ useEffect(() => {
+ setSearchQuery('')
+ setFilterTypes([])
+ setMinSizeBytes(0)
+ setFilterAnchor(null)
+ }, [pathKey])
+
+ const handleCrumbClick = (index) => {
+ if (!onNavigate) return
+ if (index < 0) {
+ onNavigate([])
+ } else {
+ onNavigate(path.slice(0, index + 1))
+ }
+ }
+
+ const canGoUp = path.length > 0
+ const handleGoUp = () => {
+ if (!canGoUp || !onNavigate) return
+ onNavigate(path.slice(0, -1))
+ }
+
+ const handleSort = (columnId) => {
+ const column = COLUMNS.find((col) => col.id === columnId)
+ if (!column) return
+ if (sortBy === columnId) {
+ setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'))
+ return
+ }
+ setSortBy(columnId)
+ setSortDir(column.defaultDir)
+ }
+
+ const availableTypes = useMemo(() => {
+ const counts = new Map()
+ for (const item of items) {
+ const label = typeLabel(item)
+ counts.set(label, (counts.get(label) ?? 0) + 1)
+ }
+ return [...counts.entries()]
+ .map(([label, count]) => ({ label, count }))
+ .sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }))
+ }, [items])
+
+ const filtersActive = filterTypes.length > 0 || minSizeBytes > 0
+ const activeFilterCount = filterTypes.length + (minSizeBytes > 0 ? 1 : 0)
+
+ const filteredItems = useMemo(
+ () =>
+ items.filter(
+ (item) =>
+ matchesSearch(item, searchQuery) &&
+ matchesFilters(item, { types: filterTypes, minSizeBytes })
+ ),
+ [items, searchQuery, filterTypes, minSizeBytes]
+ )
+
+ const sortedItems = useMemo(() => {
+ return [...filteredItems].sort((a, b) => compareItems(a, b, sortBy, sortDir))
+ }, [filteredItems, sortBy, sortDir])
+
+ const checkedIdSet = useMemo(() => new Set(checkedIds), [checkedIds])
+ const allChecked =
+ sortedItems.length > 0 && sortedItems.every((item) => checkedIdSet.has(item.id))
+ const someChecked = sortedItems.some((item) => checkedIdSet.has(item.id))
+ const searchActive = searchQuery.trim().length > 0
+ const noMatches =
+ (searchActive || filtersActive) && items.length > 0 && sortedItems.length === 0
+ const searchPlaceholder = canGoUp ? 'Search libraries…' : 'Search sites…'
+
+ const clearFilters = () => {
+ setFilterTypes([])
+ setMinSizeBytes(0)
+ }
+
+ const toggleType = (label) => {
+ setFilterTypes((prev) =>
+ prev.includes(label) ? prev.filter((value) => value !== label) : [...prev, label]
+ )
+ }
+
+ const handleToggleAll = (event) => {
+ event.stopPropagation()
+ if (!onCheckedChange) return
+ if (allChecked) {
+ onCheckedChange([])
+ } else {
+ onCheckedChange(sortedItems.map((item) => item.id))
+ }
+ }
+
+ const handleToggleOne = (itemId) => {
+ if (!onCheckedChange) return
+ if (checkedIdSet.has(itemId)) {
+ onCheckedChange(checkedIds.filter((id) => id !== itemId))
+ } else {
+ onCheckedChange([...checkedIds, itemId])
+ }
+ }
+
+ // Row click selects that row only; click again clears; Ctrl/Cmd+click toggles multi-select.
+ const handleRowActivate = (event, item) => {
+ if (!onCheckedChange) {
+ onSelect?.(item)
+ return
+ }
+ if (event.ctrlKey || event.metaKey) {
+ handleToggleOne(item.id)
+ } else if (checkedIds.length === 1 && checkedIds[0] === item.id) {
+ onCheckedChange([])
+ } else {
+ onCheckedChange([item.id])
+ }
+ onSelect?.(item)
+ }
+
+ const showTable = !isFetching && (canGoUp || items.length > 0)
+
+ return (
+
+
+
+
+ handleCrumbClick(-1)}
+ sx={{ cursor: 'pointer' }}
+ >
+ Sites
+
+ {path.map((crumb, index) => {
+ const isLast = index === path.length - 1
+ if (isLast) {
+ return (
+
+ {crumb.displayName ?? crumb.name}
+
+ )
+ }
+ return (
+ handleCrumbClick(index)}
+ sx={{ cursor: 'pointer' }}
+ >
+ {crumb.displayName ?? crumb.name}
+
+ )
+ })}
+
+
+ setSearchQuery(event.target.value)}
+ placeholder={searchPlaceholder}
+ aria-label={searchPlaceholder}
+ disabled={isFetching}
+ sx={{
+ width: { xs: '100%', sm: 240 },
+ flex: { xs: 1, sm: 'none' },
+ '& .MuiOutlinedInput-root': {
+ height: 40,
+ boxSizing: 'border-box',
+ },
+ '& .MuiInputAdornment-root': {
+ height: 'auto',
+ maxHeight: 'none',
+ marginTop: '0 !important',
+ },
+ }}
+ InputProps={{
+ startAdornment: (
+
+
+
+ ),
+ endAdornment: searchQuery ? (
+
+ setSearchQuery('')}
+ edge="end"
+ sx={{ p: 0.5 }}
+ >
+
+
+
+ ) : null,
+ }}
+ />
+
+ }
+ onClick={(event) => setFilterAnchor(event.currentTarget)}
+ disabled={isFetching || items.length === 0}
+ sx={{
+ height: 40,
+ minHeight: 40,
+ boxSizing: 'border-box',
+ px: 1.5,
+ py: 0,
+ }}
+ >
+ Filters
+
+
+ setFilterAnchor(null)}
+ anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
+ transformOrigin={{ vertical: 'top', horizontal: 'right' }}
+ slotProps={{ paper: { sx: { width: 300, p: 2 } } }}
+ >
+
+
+ Filters
+
+ Clear
+
+
+
+
+
+ Type
+
+ {availableTypes.length === 0 ? (
+
+ No types in this list.
+
+ ) : (
+
+ {availableTypes.map(({ label, count }) => (
+ toggleType(label)}
+ />
+ }
+ label={
+
+ {label}{' '}
+
+ ({count})
+
+
+ }
+ sx={{ mr: 0, ml: 0 }}
+ />
+ ))}
+
+ )}
+
+
+
+
+
+
+ Minimum size
+
+ setMinSizeBytes(Number(event.target.value))}
+ >
+ {SIZE_FILTERS.map((option) => (
+ }
+ label={{option.label} }
+ sx={{ mr: 0, ml: 0 }}
+ />
+ ))}
+
+
+
+
+
+
+
+ {filtersActive ? (
+
+ {filterTypes.map((label) => (
+ toggleType(label)}
+ />
+ ))}
+ {minSizeBytes > 0 ? (
+ setMinSizeBytes(0)}
+ />
+ ) : null}
+
+ Clear filters
+
+
+ ) : null}
+
+ {error ? (
+ {typeof error === 'string' ? error : 'Failed to load items.'}
+ ) : null}
+
+ {isFetching ? (
+
+
+
+ ) : !showTable ? (
+
+ {emptyMessage}
+
+ ) : (
+
+
+ theme.palette.mode === 'dark'
+ ? theme.palette.background.default
+ : alpha(theme.palette.neutral[200], 0.4),
+ backgroundImage: 'none',
+ },
+ }}
+ >
+
+
+
+
+
+ {COLUMNS.map((column) => (
+
+ handleSort(column.id)}
+ sx={
+ column.align === 'right'
+ ? { flexDirection: 'row-reverse', ml: 'auto' }
+ : column.align === 'center'
+ ? { mx: 'auto' }
+ : undefined
+ }
+ >
+ {column.label}
+
+
+ ))}
+
+
+
+
+ {canGoUp ? (
+ {
+ if (event.key === 'Enter') handleGoUp()
+ }}
+ sx={{ cursor: 'pointer' }}
+ >
+
+
+
+
+
+ ..
+
+
+ Go up
+
+
+
+
+
+ —
+
+
+
+
+ —
+
+
+
+
+ —
+
+
+
+
+ —
+
+
+
+
+ —
+
+
+
+
+ ) : null}
+ {noMatches ? (
+
+
+
+ {searchActive && filtersActive
+ ? `No matches for “${searchQuery.trim()}” with the current filters.`
+ : searchActive
+ ? `No matches for “${searchQuery.trim()}”.`
+ : 'No items match the current filters.'}
+
+
+
+ ) : null}
+ {sortedItems.length === 0 && canGoUp && !searchActive && !filtersActive ? (
+
+
+
+ {emptyMessage}
+
+
+
+ ) : null}
+ {sortedItems.map((item) => {
+ const checked = checkedIdSet.has(item.id)
+ const isSite =
+ item.type === 'site' || item.canOpen
+ const Icon = isSite ? (checked ? FolderOpen : Folder) : FolderShared
+
+ return (
+ handleRowActivate(event, item)}
+ onDoubleClick={() => {
+ if (item.canOpen) onOpen?.(item)
+ }}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ if (item.canOpen) onOpen?.(item)
+ else handleRowActivate(event, item)
+ }
+ }}
+ sx={{
+ cursor: 'pointer',
+ borderLeft: (theme) =>
+ checked
+ ? `3px solid ${theme.palette.warning.main}`
+ : '3px solid transparent',
+ '&.Mui-selected': {
+ bgcolor: (theme) =>
+ alpha(
+ theme.palette.warning.main,
+ theme.palette.mode === 'dark' ? 0.22 : 0.14
+ ),
+ },
+ '&.Mui-selected:hover': {
+ bgcolor: (theme) =>
+ alpha(
+ theme.palette.warning.main,
+ theme.palette.mode === 'dark' ? 0.3 : 0.2
+ ),
+ },
+ }}
+ >
+ {
+ event.stopPropagation()
+ handleToggleOne(item.id)
+ }}
+ >
+ handleToggleOne(item.id)}
+ onClick={(event) => event.stopPropagation()}
+ color="warning"
+ inputProps={{
+ 'aria-label': `Select ${item.displayName ?? item.name ?? 'item'}`,
+ }}
+ />
+
+
+
+
+
+ {item.displayName ?? item.name}
+
+
+
+ event.stopPropagation()}>
+ {item.webUrl ? (
+
+
+
+
+
+ ) : (
+
+ —
+
+ )}
+
+
+
+ {item.siteType || '—'}
+
+
+
+
+ {formatFileCount(item.fileCount)}
+
+
+
+ {formatSizeMbTooltip(item.storageUsedInBytes) ? (
+
+
+ {formatSizeGbLabel(item.storageUsedInBytes)}
+
+
+ ) : (
+
+ —
+
+ )}
+
+
+
+ {formatDate(item.createdDateTime)}
+
+
+ event.stopPropagation()}
+ >
+
+
+
+ )
+ })}
+
+
+
+ )}
+
+
+ )
+}
+
+CippSharePointFolderView.propTypes = {
+ items: PropTypes.array,
+ isFetching: PropTypes.bool,
+ error: PropTypes.any,
+ path: PropTypes.array,
+ onNavigate: PropTypes.func,
+ /** @deprecated Selection is driven by checkedIds; kept for optional side-effects. */
+ selectedId: PropTypes.string,
+ onSelect: PropTypes.func,
+ checkedIds: PropTypes.arrayOf(PropTypes.string),
+ onCheckedChange: PropTypes.func,
+ onOpen: PropTypes.func,
+ rowActions: PropTypes.array,
+ emptyMessage: PropTypes.string,
+}
diff --git a/src/components/CippComponents/CippSharePointPermissionEditor.jsx b/src/components/CippComponents/CippSharePointPermissionEditor.jsx
index ae4cb2e903db..477540f1868f 100644
--- a/src/components/CippComponents/CippSharePointPermissionEditor.jsx
+++ b/src/components/CippComponents/CippSharePointPermissionEditor.jsx
@@ -50,7 +50,7 @@ export const CippSharePointPermissionEditor = ({
validators={{ required: "A group display name is required" }}
/>
-
+
-
+
theme.breakpoints.down('md'))
const formControls = actions.reduce((acc, action) => {
if (action.form) {
@@ -109,6 +113,10 @@ const CippSpeedDial = ({
}
}, [speedDialOpen])
+ if (mdDown) {
+ return null
+ }
+
return (
<>
{
const activeSponsors = getActiveSponsors();
-export const CippSponsor = () => {
+// `compact` trims the vertical footprint for the mobile nav drawer, where this sits pinned
+// below a scrolling menu and every pixel it takes is a pixel of navigation lost.
+export const CippSponsor = ({ compact = false }) => {
const pathname = usePathname();
const [selectedSponsor, setSelectedSponsor] = useState(() => selectRandomSponsor(activeSponsors));
const currentSettings = useSettings();
@@ -72,7 +74,12 @@ export const CippSponsor = () => {
This application is sponsored by
@@ -81,8 +88,8 @@ export const CippSponsor = () => {
display: "flex",
justifyContent: "center",
alignItems: "center",
- height: "55px",
- mb: 1,
+ height: compact ? "38px" : "55px",
+ mb: compact ? 0.5 : 1,
}}
>
@@ -91,9 +98,9 @@ export const CippSponsor = () => {
alt={randomimg.altText}
style={{
cursor: "pointer",
- maxHeight: "50px",
+ maxHeight: compact ? "34px" : "50px",
width: "auto",
- maxWidth: "150px",
+ maxWidth: compact ? "130px" : "150px",
}}
onClick={() => window.open(randomimg.link)}
/>
diff --git a/src/components/CippComponents/CippSupportBundleDialog.jsx b/src/components/CippComponents/CippSupportBundleDialog.jsx
new file mode 100644
index 000000000000..e90efc21796f
--- /dev/null
+++ b/src/components/CippComponents/CippSupportBundleDialog.jsx
@@ -0,0 +1,345 @@
+import { useEffect, useRef, useState } from 'react'
+import {
+ Alert,
+ Button,
+ CircularProgress,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogContentText,
+ DialogTitle,
+ FormControlLabel,
+ Stack,
+ Switch,
+ Typography,
+} from '@mui/material'
+import {
+ Download,
+ FiberManualRecord,
+ PlayArrow,
+ Stop,
+} from '@mui/icons-material'
+import { useQueryClient } from '@tanstack/react-query'
+import { useSettings } from '../../hooks/use-settings'
+import {
+ armSupportRecorder,
+ disarmSupportRecorder,
+ downloadSupportBundle,
+ getSupportRecording,
+ getSupportRecordingCount,
+ redactBundle,
+ stripTokens,
+} from '../../utils/support-bundle'
+
+// The fixed sections go through fetch() rather than axios on purpose: the armed recorder
+// captures all axios traffic, and the network section should contain only what the page
+// (or the user's recorded actions) actually requested.
+const fetchJson = async (url) => {
+ try {
+ const response = await fetch(url, { credentials: 'include' })
+ const parsed = await response.json().catch(() => null)
+ return response.ok ? parsed : { unavailable: response.status, body: parsed }
+ } catch (error) {
+ return { unavailable: String(error?.message ?? error) }
+ }
+}
+
+const CippSupportBundleDialog = ({ open, onClose, onRecordingChange }) => {
+ const queryClient = useQueryClient()
+ const settings = useSettings()
+ const [phase, setPhase] = useState('options')
+ const [redact, setRedact] = useState(true)
+ const [bundle, setBundle] = useState(null)
+ const [redactionSummary, setRedactionSummary] = useState(null)
+ const [progress, setProgress] = useState(0)
+ const [errorMessage, setErrorMessage] = useState(null)
+ // True while a manual recording is running. It deliberately survives the dialog being
+ // closed - the user closes it, reproduces the issue, and comes back to stop. The
+ // dialog stays mounted in _app, so this state outlives the close.
+ const [recording, setRecording] = useState(false)
+ // Invalidates a run when it is cancelled, so a stale run cannot finish later and
+ // overwrite the state of a newer one.
+ const runToken = useRef(0)
+ const modeRef = useRef('page')
+
+ // Reopening lands on the options screen - unless a manual recording is running, in
+ // which case it lands back on the recording screen. Adjusted during render (the
+ // React-sanctioned alternative to setState-in-effect).
+ const [prevOpen, setPrevOpen] = useState(open)
+ if (open !== prevOpen) {
+ setPrevOpen(open)
+ if (open) {
+ if (recording) {
+ setPhase('recording')
+ setProgress(getSupportRecordingCount())
+ } else {
+ setPhase('options')
+ setBundle(null)
+ setRedactionSummary(null)
+ setErrorMessage(null)
+ setProgress(0)
+ }
+ }
+ }
+
+ // Closing cancels a page capture in flight; a manual recording keeps running.
+ useEffect(() => {
+ if (!open && !recording) {
+ runToken.current++
+ disarmSupportRecorder()
+ }
+ }, [open, recording])
+
+ // Live request counter while the dialog is showing an armed recorder.
+ useEffect(() => {
+ if (!open || (phase !== 'collecting' && phase !== 'recording')) return
+ const interval = setInterval(
+ () => setProgress(getSupportRecordingCount()),
+ 300
+ )
+ return () => clearInterval(interval)
+ }, [open, phase])
+
+ const assemble = async (token) => {
+ const localVersion = await fetchJson('/version.json')
+ const [instance, me, authMe] = await Promise.all([
+ fetchJson(
+ `/api/GetVersion?LocalVersion=${encodeURIComponent(localVersion?.version ?? '')}`
+ ),
+ fetchJson('/api/me'),
+ fetchJson('/.auth/me'),
+ ])
+ if (token !== runToken.current) return
+ disarmSupportRecorder()
+ const network = getSupportRecording()
+ let assembled = {
+ schemaVersion: 1,
+ generatedAt: new Date().toISOString(),
+ instanceHostname: window.location.hostname,
+ redaction: { enabled: redact },
+ client: {
+ captureMode: modeRef.current,
+ path: window.location.pathname,
+ tenant: settings.currentTenant ?? null,
+ userAgent: navigator.userAgent,
+ frontendVersion: localVersion?.version ?? null,
+ },
+ instance,
+ user: { me, authMe },
+ network,
+ }
+ // Tokens are live credentials and are stripped from every bundle, before and
+ // independent of the optional identifier redaction.
+ const stripped = stripTokens(assembled)
+ assembled = stripped.bundle
+ assembled.tokensRemoved = stripped.removed
+ if (redact) {
+ // The instance's own hostname identifies the installation, not a customer
+ // tenant - support needs it, so it survives redaction.
+ const redacted = redactBundle(assembled, {
+ keepHostnames: [window.location.hostname],
+ })
+ assembled = redacted.bundle
+ assembled.redaction = { enabled: true, ...redacted.summary }
+ setRedactionSummary(redacted.summary)
+ }
+ setBundle(assembled)
+ setProgress(network.length)
+ setPhase('ready')
+ }
+
+ const failRun = (token, error) => {
+ if (token !== runToken.current) return
+ disarmSupportRecorder()
+ setErrorMessage(String(error?.message ?? error))
+ setPhase('error')
+ }
+
+ const handleCapturePage = async () => {
+ const token = ++runToken.current
+ modeRef.current = 'page'
+ setPhase('collecting')
+ setProgress(0)
+ armSupportRecorder()
+ try {
+ // Force every query mounted on the current page to hit the API again - the
+ // recorder only sees axios traffic, so cache reads must become real requests.
+ await queryClient.refetchQueries({ type: 'active' })
+ await assemble(token)
+ } catch (error) {
+ failRun(token, error)
+ }
+ }
+
+ const handleStartRecording = () => {
+ ++runToken.current
+ modeRef.current = 'recording'
+ setRecording(true)
+ onRecordingChange?.(true)
+ armSupportRecorder()
+ onClose()
+ }
+
+ const handleStopRecording = async () => {
+ const token = ++runToken.current
+ setRecording(false)
+ onRecordingChange?.(false)
+ setPhase('collecting')
+ try {
+ await assemble(token)
+ } catch (error) {
+ failRun(token, error)
+ }
+ }
+
+ const handleDiscardRecording = () => {
+ ++runToken.current
+ setRecording(false)
+ onRecordingChange?.(false)
+ disarmSupportRecorder()
+ setPhase('options')
+ setProgress(0)
+ }
+
+ const failedCount =
+ bundle?.network?.filter((call) => !call.success).length ?? 0
+ const capturedFrom =
+ bundle?.client?.captureMode === 'recording'
+ ? 'during the recording'
+ : 'from this page'
+
+ return (
+
+ Generate Support File
+
+ {phase === 'options' && (
+
+
+ Capture this page's API requests now, or record while you
+ reproduce an issue. Either way the file also includes the instance
+ version, hosting and update details, and your signed-in identity
+ and roles.
+
+ setRedact(event.target.checked)}
+ />
+ }
+ label="Redact tenant IDs, domains and email addresses"
+ />
+
+ )}
+ {phase === 'recording' && (
+
+
+
+
+ Recording — {progress} request{progress === 1 ? '' : 's'}{' '}
+ captured so far.
+
+
+
+ Close this dialog, reproduce the issue, then click the recording
+ indicator to come back and stop. Reloading the browser discards
+ the recording.
+
+
+ )}
+ {phase === 'collecting' && (
+
+
+
+ Collecting — {progress} request{progress === 1 ? '' : 's'}{' '}
+ captured...
+
+
+ )}
+ {phase === 'ready' && (
+
+
+ Captured {bundle.network.length} request
+ {bundle.network.length === 1 ? '' : 's'} {capturedFrom}
+ {failedCount > 0 ? `, of which ${failedCount} failed` : ''}, along
+ with the instance version, hosting and update details, and your
+ signed-in identity and roles.
+
+ {redactionSummary ? (
+
+ Redacted {redactionSummary.emails} email address
+ {redactionSummary.emails === 1 ? '' : 'es'},{' '}
+ {redactionSummary.guids} GUID
+ {redactionSummary.guids === 1 ? '' : 's'} and{' '}
+ {redactionSummary.domains} domain
+ {redactionSummary.domains === 1 ? '' : 's'}.
+
+ ) : (
+
+ The file contains unredacted data from the current page, your
+ user identity, and instance details. Authentication tokens are
+ always removed. Only share it with support.
+
+ )}
+
+ )}
+ {phase === 'error' && (
+
+ Could not generate the support file: {errorMessage}
+
+ )}
+
+
+ {phase === 'options' && (
+ <>
+ Cancel
+ }
+ onClick={handleStartRecording}
+ >
+ Record Actions
+
+ }
+ onClick={handleCapturePage}
+ >
+ Capture This Page
+
+ >
+ )}
+ {phase === 'recording' && (
+ <>
+ Discard
+ Continue Recording
+ }
+ onClick={handleStopRecording}
+ >
+ Stop & Generate
+
+ >
+ )}
+ {(phase === 'collecting' || phase === 'ready' || phase === 'error') && (
+ <>
+
+ {phase === 'ready' ? 'Close' : 'Cancel'}
+
+ }
+ disabled={phase !== 'ready'}
+ onClick={() => downloadSupportBundle(bundle)}
+ >
+ Download
+
+ >
+ )}
+
+
+ )
+}
+
+export default CippSupportBundleDialog
diff --git a/src/components/CippComponents/CippTabNavigationSection.jsx b/src/components/CippComponents/CippTabNavigationSection.jsx
new file mode 100644
index 000000000000..59ae1d7fcd69
--- /dev/null
+++ b/src/components/CippComponents/CippTabNavigationSection.jsx
@@ -0,0 +1,57 @@
+import {
+ List,
+ ListItemButton,
+ ListItemIcon,
+ ListItemText,
+ ListSubheader,
+} from '@mui/material'
+import { Check } from '@mui/icons-material'
+import { getIconByName } from '../../utils/icon-registry'
+import { useTabNavigation } from '../../layouts/tab-navigation-context'
+
+/**
+ * The tab bar as sheet rows. Rendered inside whichever bottom sheet owns the mobile
+ * bottom-right corner, so a page never shows two competing navigation affordances.
+ */
+export const CippTabNavigationSection = ({ title = 'Views', onNavigate }) => {
+ const tabNav = useTabNavigation()
+
+ if (!tabNav?.enabled || !tabNav.tabs?.length) return null
+
+ return (
+
+ {title}
+
+ ) : null
+ }
+ >
+ {tabNav.tabs.map((tab) => {
+ const selected = tab.path === tabNav.currentPath
+ return (
+ {
+ onNavigate?.()
+ // Already here — the sheet closing is the whole interaction.
+ if (!selected) tabNav.onNavigate?.(tab.path)
+ }}
+ >
+
+ {getIconByName(tab.icon, { fontSize: 'small' })}
+
+
+ {selected && }
+
+ )
+ })}
+
+ )
+}
diff --git a/src/components/CippComponents/CippTabPicker.jsx b/src/components/CippComponents/CippTabPicker.jsx
new file mode 100644
index 000000000000..ba19573da8ad
--- /dev/null
+++ b/src/components/CippComponents/CippTabPicker.jsx
@@ -0,0 +1,100 @@
+import { useState } from 'react'
+import { Box, ButtonBase, Typography } from '@mui/material'
+import { visuallyHidden } from '@mui/utils'
+import { KeyboardArrowDown } from '@mui/icons-material'
+import { CippBottomSheet } from './CippBottomSheet'
+import { CippTabNavigationSection } from './CippTabNavigationSection'
+import { getIconByName } from '../../utils/icon-registry'
+import { useTabNavigation } from '../../layouts/tab-navigation-context'
+
+/**
+ * The mobile replacement for a tabbed layout's tab bar: a collapsed trigger that opens the tab
+ * list as a bottom sheet.
+ *
+ * Navigation deliberately lives in the content flow rather than in the page FAB — a FAB is for a
+ * screen's primary action, and putting destinations there also made them unreachable whenever
+ * something else owned the corner (a card list in select mode draws no FAB at all).
+ *
+ * Two presentations, one behaviour:
+ * block the default, and what every page gets — a full-width row in the slot the desktop
+ * tab bar occupies. Same control in the same place on every tabbed page.
+ * compact a chip beside a heading. Only HeaderedTabbedLayout, whose title row has an empty
+ * right half below md, so navigation there costs no vertical space at all.
+ */
+export const CippTabPicker = (props) => {
+ const { variant = 'block', sx } = props
+
+ const [open, setOpen] = useState(false)
+ const tabNav = useTabNavigation()
+ const tabs = tabNav?.tabs ?? []
+ // One destination is not navigation. Two pages (View Group, View Device) have a single tab and
+ // used to get a FAB whose sheet offered the page you were already on.
+ if (!tabNav?.enabled || tabs.length < 2) return null
+
+ const current = tabs.find((tab) => tab.path === tabNav.currentPath)
+ const label = current?.label ?? 'Views'
+ const isCompact = variant === 'compact'
+
+ return (
+ <>
+ setOpen(true)}
+ aria-haspopup="dialog"
+ sx={{
+ minWidth: 0,
+ display: 'flex',
+ alignItems: 'center',
+ textAlign: 'left',
+ gap: 0.75,
+ borderRadius: 1,
+ ...(isCompact
+ ? {
+ flexShrink: 0,
+ // Long labels ("Policies and Settings Deployed" is 30 characters) must not push
+ // the heading beside them off the row.
+ maxWidth: '50%',
+ height: 40,
+ px: 1.25,
+ bgcolor: 'action.hover',
+ }
+ : {
+ // Full-width tap target, heading clothes: the chevron is the affordance.
+ width: '100%',
+ minHeight: 44,
+ justifyContent: 'flex-start',
+ }),
+ ...sx,
+ }}
+ >
+ {/* No leading icon in the compact chip: it shares a row with a heading that can be a
+ tenant or user name, and the ~28px it costs comes straight out of that heading. */}
+ {!isCompact &&
+ getIconByName(current?.icon, {
+ fontSize: 'small',
+ sx: { flexShrink: 0, color: 'text.secondary' },
+ })}
+
+ {label}
+
+ {/* Not an aria-label: overriding the name would leave the visible text out of it, and
+ a voice-control user saying "Manage Drift" could no longer activate this. The
+ hidden suffix extends the name instead of replacing it. */}
+
+ switch view
+
+ {/* Compact rides the control's right edge; the heading form keeps the chevron
+ beside the text, where a title's disclosure affordance belongs. */}
+
+
+ setOpen(false)} title="Views">
+ setOpen(false)} />
+
+ >
+ )
+}
diff --git a/src/components/CippComponents/CippTableDialog.jsx b/src/components/CippComponents/CippTableDialog.jsx
index e31d4485263b..d1c2e8076628 100644
--- a/src/components/CippComponents/CippTableDialog.jsx
+++ b/src/components/CippComponents/CippTableDialog.jsx
@@ -1,30 +1,38 @@
-import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
-import { Stack } from "@mui/system";
-import { CippDataTable } from "../CippTable/CippDataTable";
-
-export const CippTableDialog = (props) => {
- const { createDialog, title, fields, api, simpleColumns, ...other } = props;
-
- return (
-
- {title}
-
-
-
-
-
-
-
- Close
-
-
-
- );
-};
+import { Button, Dialog, DialogActions, DialogContent, DialogTitle, useMediaQuery } from "@mui/material";
+import { Stack } from "@mui/system";
+import { CippDataTable } from "../CippTable/CippDataTable";
+
+export const CippTableDialog = (props) => {
+ const { createDialog, title, fields, api, simpleColumns, ...other } = props;
+ // Fullscreen on phones so the nested card list gets the viewport (CippApiDialog precedent)
+ const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md"));
+
+ return (
+
+ {title}
+
+
+
+
+
+
+
+ Close
+
+
+
+ );
+};
diff --git a/src/components/CippComponents/CippTablePage.jsx b/src/components/CippComponents/CippTablePage.jsx
index b95db81b5a0c..af4881a97b81 100644
--- a/src/components/CippComponents/CippTablePage.jsx
+++ b/src/components/CippComponents/CippTablePage.jsx
@@ -2,6 +2,7 @@ import { Alert, Card, Divider } from "@mui/material";
import { Box, Container, Stack } from "@mui/system";
import { CippDataTable } from "../CippTable/CippDataTable";
import { useSettings } from "../../hooks/use-settings";
+import { useTableViewMode } from "../../hooks/use-breakpoint";
import { CippHead } from "./CippHead";
import { useState, useEffect } from "react";
@@ -29,47 +30,69 @@ export const CippTablePage = (props) => {
...other
} = props;
const tenant = useSettings().currentTenant;
+ const viewMode = useTableViewMode({ viewMode: other.viewMode });
+ const isCardView = viewMode === "cards";
// Use initialFilters if provided, otherwise use regular filters
const activeFilters = initialFilters || filters;
+
+ // Pages without an explicit queryKey have always keyed their query on the title —
+ // which embeds the tenant. Card view drops the tenant from the DISPLAY title, so the
+ // cache key must keep carrying it explicitly or tenant switches serve stale data.
+ const effectiveQueryKey =
+ queryKey ?? (tenantInTitle && tenant !== null ? `${title} - ${tenant}` : title);
+
+ const table = (
+
+ );
+
return (
<>
-
-
+
+
{tableFilter}
{tenantInTitle && (!tenant || tenant === null) && (
No tenant selected. Please select a tenant from the dropdown above.
)}
-
-
-
-
-
+ >
+
+
+ {table}
+
+ )}
diff --git a/src/components/CippComponents/CippTemplateCatalog.jsx b/src/components/CippComponents/CippTemplateCatalog.jsx
index 71b004dbbc14..ab2d941ee4ef 100644
--- a/src/components/CippComponents/CippTemplateCatalog.jsx
+++ b/src/components/CippComponents/CippTemplateCatalog.jsx
@@ -320,7 +320,7 @@ const CompactTemplateList = memo(
mb: 1,
bgcolor: 'background.paper',
'&:hover': { bgcolor: 'action.hover' },
- pr: 20,
+ pr: { xs: 6, md: 20 },
}}
>
{
{/* Custom Variable - Two-field input */}
{watchedRules?.[ruleIndex]?.property?.type === "customVariable" ? (
-
+
{
}}
/>
-
+
{
clearTimeout(routerUpdateTimeoutRef.current);
}
- // Cancel all in-flight queries before changing tenant
- queryClient.cancelQueries();
+ // Only cancel on a real tenant change; cancelling the initial-load URL backfill
+ // aborts mount fetches that react-query never retries.
+ if (query.tenantFilter && query.tenantFilter !== currentTenant.value) {
+ queryClient.cancelQueries();
+ }
// Update router only - let the URL watcher handle settings
query.tenantFilter = currentTenant.value;
@@ -405,7 +408,9 @@ export const CippTenantSelector = React.forwardRef((props, ref) => {
disableClearable={true}
creatable={false}
multiple={multiple}
- sx={{ width: width ? width : "400px" }}
+ // Full width below md by default: the hard 400px overflowed any narrow container
+ // this selector was dropped into (the old 80%-wide mobile drawer most visibly).
+ sx={{ width: width ? width : { xs: "100%", md: "400px" } }}
placeholder={
tenantList.isFetching
? "Loading Tenants..."
diff --git a/src/components/CippComponents/CippTranslations.jsx b/src/components/CippComponents/CippTranslations.jsx
index 4aab78290cd5..b184235e8072 100644
--- a/src/components/CippComponents/CippTranslations.jsx
+++ b/src/components/CippComponents/CippTranslations.jsx
@@ -122,4 +122,6 @@ export const CippTranslations = {
resellerPartnerDelegatedAdmin: 'Direct Reseller',
valueAddedResellerPartnerDelegatedAdmin: 'Indirect Reseller',
unknownFutureValue: 'Unknown',
+ devicePrepData: 'Corporate Identifiers',
+ overwriteExisting: 'Overwrite Existing Identifiers',
}
diff --git a/src/components/CippComponents/CippTransportRuleDrawer.jsx b/src/components/CippComponents/CippTransportRuleDrawer.jsx
index 37e4bcf308fb..2458b12cbf46 100644
--- a/src/components/CippComponents/CippTransportRuleDrawer.jsx
+++ b/src/components/CippComponents/CippTransportRuleDrawer.jsx
@@ -995,7 +995,7 @@ export const CippTransportRuleDrawer = ({
return (
-
+
-
+
-
+
-
+
-
+
-
+
{
multiPost: false,
condition: () => canWriteUser,
},
+ {
+ label: 'Require Password Change at Next Logon',
+ type: 'POST',
+ icon: ,
+ url: '/api/ExecRequirePasswordChange',
+ data: {
+ ID: 'id',
+ },
+ confirmText:
+ 'Require [userPrincipalName] to change their password at next logon? This does not reset the password. Not supported for directory-synced accounts.',
+ multiPost: false,
+ condition: () => canWriteUser,
+ },
{
label: 'Set Password Expiration',
type: 'POST',
diff --git a/src/components/CippComponents/CippUserSwitcher.jsx b/src/components/CippComponents/CippUserSwitcher.jsx
new file mode 100644
index 000000000000..444de74ef82c
--- /dev/null
+++ b/src/components/CippComponents/CippUserSwitcher.jsx
@@ -0,0 +1,27 @@
+import { CippEntitySwitcher } from "./CippEntitySwitcher";
+
+/**
+ * The View User pages' title-as-switcher: CippEntitySwitcher preset over the tenant's
+ * user list, swapping userId so the current tab (View, Edit, Exchange…) is preserved.
+ */
+export const CippUserSwitcher = ({ title, currentUserId, tenantFilter }) => (
+ user.userPrincipalName}
+ />
+);
diff --git a/src/components/CippComponents/CippVariableAutocomplete.jsx b/src/components/CippComponents/CippVariableAutocomplete.jsx
index 39d1b49c4047..07ba551b218b 100644
--- a/src/components/CippComponents/CippVariableAutocomplete.jsx
+++ b/src/components/CippComponents/CippVariableAutocomplete.jsx
@@ -277,8 +277,12 @@ export const CippVariableAutocomplete = React.memo(
borderRadius: 1,
maxHeight: 240,
overflow: "auto",
- minWidth: 300,
- maxWidth: 500,
+ // Clamped to the viewport: the Paper shrink-to-fits against unclamped variable
+ // descriptions, and popper.js can only shift a too-wide popper, not shrink it —
+ // at the 500px cap a phone got ~110px hanging off the right edge, scrolling the
+ // whole document sideways.
+ minWidth: "min(300px, calc(100vw - 32px))",
+ maxWidth: "min(500px, calc(100vw - 32px))",
}}
onClick={(e) => {
e.stopPropagation();
diff --git a/src/components/CippComponents/EnrollmentProfileTabs.jsx b/src/components/CippComponents/EnrollmentProfileTabs.jsx
index cfed94ec5172..1288c9a97e74 100644
--- a/src/components/CippComponents/EnrollmentProfileTabs.jsx
+++ b/src/components/CippComponents/EnrollmentProfileTabs.jsx
@@ -16,9 +16,12 @@ import {
ContentCopy,
Delete,
EventAvailable,
+ LaptopChromebook,
+ LinkOff,
QrCode2,
Sync,
} from '@mui/icons-material'
+import { UserGroupIcon } from '@heroicons/react/24/outline'
import { CippHead } from './CippHead.jsx'
import { CippDataTable } from '../CippTable/CippDataTable.js'
import { CippInfoBar } from '../CippCards/CippInfoBar.jsx'
@@ -419,7 +422,140 @@ export const AndroidEnterpriseEnrollmentProfiles = () => {
export const WindowsAutopilotEnrollmentProfiles = () => {
const currentTenant = useSettings().currentTenant
+
+ const groupsQuery = ApiGetCall({
+ url: '/api/ListGroups',
+ data: { tenantFilter: currentTenant },
+ queryKey: `ListGroups-${currentTenant}`,
+ waiting: Boolean(currentTenant),
+ })
+ const groupMap = useMemo(() => {
+ const map = {}
+ if (groupsQuery.data) {
+ for (const g of groupsQuery.data) {
+ if (g.id) map[g.id] = g.displayName
+ }
+ }
+ return map
+ }, [groupsQuery.data])
+
const autopilotActions = [
+ {
+ label: 'Assign to All Devices',
+ type: 'POST',
+ icon: ,
+ url: '/api/ExecAssignAutopilotProfile',
+ data: {
+ ProfileId: 'id',
+ ProfileName: 'displayName',
+ AssignTo: '!AllDevices',
+ },
+ confirmText:
+ 'Are you sure you want to assign "[displayName]" to all devices?',
+ color: 'info',
+ multiPost: false,
+ allowResubmit: true,
+ relatedQueryKeys: [`AutopilotProfiles-${currentTenant}`],
+ },
+ {
+ label: 'Assign to Custom Group(s)',
+ type: 'POST',
+ icon: ,
+ url: '/api/ExecAssignAutopilotProfile',
+ confirmText: 'Select the target groups for "[displayName]".',
+ color: 'info',
+ multiPost: false,
+ allowResubmit: true,
+ relatedQueryKeys: [`AutopilotProfiles-${currentTenant}`],
+ fields: [
+ {
+ type: 'autoComplete',
+ name: 'GroupIds',
+ label: 'Group(s)',
+ multiple: true,
+ creatable: false,
+ validators: { required: 'Please select at least one group' },
+ api: {
+ url: '/api/ListGroups',
+ queryKey: `ListGroups-${currentTenant}`,
+ tenantFilter: currentTenant,
+ labelField: (option) =>
+ option?.groupType
+ ? `${option.displayName} (${option.groupType})`
+ : (option?.displayName ?? ''),
+ valueField: 'id',
+ showRefresh: true,
+ },
+ },
+ ],
+ customDataformatter: (row, action, formData) => ({
+ tenantFilter: currentTenant,
+ ProfileId: row.id,
+ ProfileName: row.displayName,
+ AssignTo: 'customGroup',
+ GroupIds: (formData?.GroupIds || []).map((g) => g.value).filter(Boolean),
+ }),
+ },
+ {
+ label: 'Remove Assignment(s)',
+ type: 'POST',
+ icon: ,
+ url: '/api/ExecAssignAutopilotProfile',
+ confirmText: 'Remove assignments from "[displayName]".',
+ color: 'warning',
+ multiPost: false,
+ allowResubmit: true,
+ relatedQueryKeys: [`AutopilotProfiles-${currentTenant}`],
+ fields: [
+ {
+ type: 'switch',
+ name: 'removeAll',
+ label: 'Remove all assignments',
+ defaultValue: true,
+ },
+ {
+ type: 'autoComplete',
+ name: 'GroupIds',
+ label: 'Assignment(s) to remove',
+ multiple: true,
+ creatable: false,
+ validators: {
+ validate: (value, formValues) => {
+ if (formValues?.removeAll) return true
+ return (Array.isArray(value) && value.length > 0) || 'Please select at least one assignment'
+ },
+ },
+ options: (row) =>
+ (row?.assignments || [])
+ .map((a) => {
+ const t = a.target?.['@odata.type'] || ''
+ if (t.endsWith('allDevicesAssignmentTarget')) {
+ return { label: 'All Devices', value: 'allDevices' }
+ }
+ if (t.endsWith('groupAssignmentTarget') && a.target?.groupId) {
+ const id = a.target.groupId
+ const name = groupMap[id]
+ return {
+ label: name ? `${name} (${id})` : id,
+ value: id,
+ }
+ }
+ return null
+ })
+ .filter(Boolean),
+ condition: { field: 'removeAll', compareType: 'is', compareValue: false },
+ },
+ ],
+ customDataformatter: (row, action, formData) => ({
+ tenantFilter: currentTenant,
+ ProfileId: row.id,
+ ProfileName: row.displayName,
+ AssignTo: formData?.removeAll ? 'RemoveAll' : 'RemoveGroups',
+ GroupIds: formData?.removeAll
+ ? []
+ : (formData?.GroupIds || []).map((g) => g.value).filter(Boolean),
+ }),
+ },
{
label: 'Delete Profile',
icon: ,
diff --git a/src/components/CippComponents/LicenseCard.jsx b/src/components/CippComponents/LicenseCard.jsx
index 5e59011903b3..b7f1d2218b27 100644
--- a/src/components/CippComponents/LicenseCard.jsx
+++ b/src/components/CippComponents/LicenseCard.jsx
@@ -145,7 +145,7 @@ export const LicenseCard = ({ data, isLoading }) => {
sx={{ pb: 1 }}
/>
-
+
{isLoading ? (
) : processedData ? (
diff --git a/src/components/CippComponents/MFACard.jsx b/src/components/CippComponents/MFACard.jsx
index 634a97decc7b..0a2cc9730fb4 100644
--- a/src/components/CippComponents/MFACard.jsx
+++ b/src/components/CippComponents/MFACard.jsx
@@ -228,7 +228,7 @@ export const MFACard = ({ data, isLoading }) => {
sx={{ pb: 1 }}
/>
-
+
{isLoading ? (
) : processedData ? (
diff --git a/src/components/CippComponents/SecureScoreCard.jsx b/src/components/CippComponents/SecureScoreCard.jsx
index e20920a01e64..6117faa2ed9d 100644
--- a/src/components/CippComponents/SecureScoreCard.jsx
+++ b/src/components/CippComponents/SecureScoreCard.jsx
@@ -11,9 +11,37 @@ import {
Tooltip as RechartsTooltip,
ReferenceLine,
} from 'recharts'
+import { useIsMobileLayout } from '../../hooks/use-breakpoint'
+
+/**
+ * Axis configuration for the score trend.
+ *
+ * Exported because it is the whole of the narrow-screen fix and there is nothing rendered to
+ * assert against: recharts reads its axis children's props directly rather than mounting them,
+ * so an XAxis cannot be captured by wrapping it.
+ *
+ * `interval: 0` draws a label for every point. Thirteen dates fit across a desktop card and
+ * overlap into one smear at 390px — "Jul 27Jul 28Jul 29". A narrow chart hands spacing back to
+ * recharts and lets it drop whatever will not fit.
+ */
+export const secureScoreAxisProps = ({ isMobile, ticks }) => ({
+ x: {
+ tick: { fontSize: isMobile ? 10 : 12 },
+ tickMargin: 8,
+ ticks: isMobile ? undefined : ticks,
+ interval: isMobile ? 'preserveStartEnd' : 0,
+ minTickGap: isMobile ? 28 : 5,
+ },
+ y: {
+ tick: { fontSize: isMobile ? 10 : 12 },
+ tickMargin: 8,
+ width: isMobile ? 34 : undefined,
+ },
+})
export const SecureScoreCard = ({ data, isLoading }) => {
const router = useRouter()
+ const isMobile = useIsMobileLayout()
return (
{
percentage: Math.round((score.currentScore / score.maxScore) * 100),
}))
const ticks = chartData.map((d) => d.date)
+ const axis = secureScoreAxisProps({ isMobile, ticks })
return (
-
+
Math.round(value)}
/>
diff --git a/src/components/CippComponents/TenantMetricsGrid.jsx b/src/components/CippComponents/TenantMetricsGrid.jsx
index 35eda0143286..404f1b6601b7 100644
--- a/src/components/CippComponents/TenantMetricsGrid.jsx
+++ b/src/components/CippComponents/TenantMetricsGrid.jsx
@@ -1,5 +1,5 @@
-import { Box, Grid, Tooltip, Avatar, Typography, Skeleton } from "@mui/material";
-import { useRouter } from "next/router";
+import { Box, Grid, Tooltip, Avatar, Typography, Skeleton } from '@mui/material'
+import { useRouter } from 'next/router'
import {
Person as UserIcon,
PersonOutline as GuestIcon,
@@ -7,72 +7,75 @@ import {
Apps as AppsIcon,
Devices as DevicesIcon,
PhoneAndroid as ManagedIcon,
-} from "@mui/icons-material";
+} from '@mui/icons-material'
const formatNumber = (num) => {
- if (num >= 1000000) return (num / 1000000).toFixed(1) + "M";
- if (num >= 1000) return (num / 1000).toFixed(1) + "K";
- return num?.toString() || "0";
-};
+ if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'
+ if (num >= 1000) return (num / 1000).toFixed(1) + 'K'
+ return num?.toString() || '0'
+}
export const TenantMetricsGrid = ({ data, isLoading }) => {
- const router = useRouter();
+ const router = useRouter()
const metrics = [
{
- label: "Users",
+ label: 'Users',
value: data?.UserCount || 0,
icon: UserIcon,
- color: "primary",
- path: "/identity/administration/users",
+ color: 'primary',
+ path: '/identity/administration/users',
},
{
- label: "Guests",
+ label: 'Guests',
value: data?.GuestCount || 0,
icon: GuestIcon,
- color: "info",
- path: "/identity/administration/users",
+ color: 'info',
+ path: '/identity/administration/users',
},
{
- label: "Groups",
+ label: 'Groups',
value: data?.GroupCount || 0,
icon: GroupIcon,
- color: "secondary",
- path: "/identity/administration/groups",
+ color: 'secondary',
+ path: '/identity/administration/groups',
},
{
- label: "Service Principals",
+ label: 'Service Principals',
value: data?.ApplicationCount || 0,
icon: AppsIcon,
- color: "error",
- path: "/tenant/administration/applications/enterprise-apps",
+ color: 'error',
+ path: '/tenant/administration/applications/enterprise-apps',
},
{
- label: "Devices",
+ label: 'Devices',
value: data?.DeviceCount || 0,
icon: DevicesIcon,
- color: "warning",
- path: "/identity/administration/devices",
+ color: 'warning',
+ path: '/identity/administration/devices',
},
{
- label: "Managed",
+ label: 'Managed',
value: data?.ManagedDeviceCount || 0,
icon: ManagedIcon,
- color: "success",
- path: "/identity/administration/devices",
+ color: 'success',
+ path: '/identity/administration/devices',
},
- ];
+ ]
const handleClick = (metric) => {
if (metric.path) {
- router.push(metric.path);
+ router.push(metric.path)
}
- };
+ }
return (
{metrics.map((metric) => {
- const IconComponent = metric.icon;
+ const IconComponent = metric.icon
+ // Two-up at every width on purpose, phones included: the tile is sized for a
+ // narrow column (28px avatar, 0.6rem label) and the dashboard reads better as a
+ // 2x3 block than as six stacked rows. mobile-layout-ok
return (
{
handleClick(metric)}
sx={{
- display: "flex",
- alignItems: "center",
+ display: 'flex',
+ alignItems: 'center',
gap: { xs: 1, sm: 1.5 },
p: { xs: 1, sm: 1.5, md: 2 },
border: 1,
- borderColor: "divider",
+ borderColor: 'divider',
borderRadius: 1,
- cursor: "pointer",
+ cursor: 'pointer',
minWidth: 0,
- transition: "all 0.2s ease-in-out",
- "&:hover": {
+ transition: 'all 0.2s ease-in-out',
+ '&:hover': {
borderColor: `${metric.color}.main`,
- backgroundColor: "action.hover",
- transform: "translateY(-2px)",
- boxShadow: "0 4px 8px rgba(0,0,0,0.1)",
+ backgroundColor: 'action.hover',
+ transform: 'translateY(-2px)',
+ boxShadow: '0 4px 8px rgba(0,0,0,0.1)',
},
}}
>
@@ -109,26 +112,38 @@ export const TenantMetricsGrid = ({ data, isLoading }) => {
flexShrink: 0,
}}
>
-
+
{metric.label}
-
- {isLoading ? : formatNumber(metric.value)}
+
+ {isLoading ? (
+
+ ) : (
+ formatNumber(metric.value)
+ )}
- );
+ )
})}
- );
-};
+ )
+}
diff --git a/src/components/CippFormPages/CippAddEditUser.jsx b/src/components/CippFormPages/CippAddEditUser.jsx
index 4143b6c9d9a6..3b7290e47d6e 100644
--- a/src/components/CippFormPages/CippAddEditUser.jsx
+++ b/src/components/CippFormPages/CippAddEditUser.jsx
@@ -10,6 +10,7 @@ import { CippFormLicenseSelector } from '../CippComponents/CippFormLicenseSelect
import { Grid } from '@mui/system'
import { ApiGetCall } from '../../api/ApiCall'
import { useSettings } from '../../hooks/use-settings'
+import { useQueryClient } from '@tanstack/react-query'
import { useWatch } from 'react-hook-form'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useRouter } from 'next/router'
@@ -158,6 +159,32 @@ const CippAddEditUser = (props) => {
AddToGroups: watcher[3],
}
+ // Duplicate-username warning. The Users table already pulled the tenant's user list into the
+ // tanstack cache when it loaded, so this reads that cache and makes no API request. The entry
+ // is an infinite query (the table pages through nextLinks), so every page must be flattened -
+ // checking one page would miss most of the tenant. Warning-only: the cache can be partial or
+ // stale, so no conflict found is never presented as the name being available.
+ const queryClient = useQueryClient()
+ const usernameValue = useWatch({ control: formControl.control, name: 'username' })
+ const primDomainValue = useWatch({ control: formControl.control, name: 'primDomain' })
+ const usernameConflict = useMemo(() => {
+ if (formType !== 'add' || !usernameValue || !primDomainValue?.value) return null
+ const cachedUsers = queryClient
+ .getQueryData([`Users - ${tenantDomain}`])
+ ?.pages?.flatMap((page) => page?.Results ?? [])
+ if (!cachedUsers?.length) return null
+ const candidateUPN = `${usernameValue}@${primDomainValue.value}`.toLowerCase()
+ const candidateSmtp = `smtp:${candidateUPN}`
+ return (
+ cachedUsers.find(
+ (user) =>
+ user?.userPrincipalName?.toLowerCase() === candidateUPN ||
+ (Array.isArray(user?.proxyAddresses) &&
+ user.proxyAddresses.some((address) => address?.toLowerCase() === candidateSmtp))
+ ) ?? null
+ )
+ }, [formType, usernameValue, primDomainValue?.value, tenantDomain, queryClient])
+
// Helper function to generate username from template format
const generateUsername = (
format,
@@ -601,6 +628,13 @@ const CippAddEditUser = (props) => {
showRefresh={true}
/>
+ {formType === 'add' && usernameConflict && (
+
+
+ {`${usernameValue}@${primDomainValue?.value} is already in use by "${usernameConflict.displayName}" (${usernameConflict.userPrincipalName}).`}
+
+
+ )}
{
Settings
-
+
{
-
+
{
compareValue="(0 available)"
labelCompare={true}
>
-
+
{
>
)}
-
+
{
{userSettingsDefaults?.userAttributes
?.filter((attribute) => attribute.value !== 'sponsor')
.map((attribute, idx) => (
-
+
{
{formType === 'add' && (
<>
-
+
{
formControl={formControl}
/>
-
+
{
formControl={formControl}
/>
-
+
{
formControl={formControl}
/>
-
+
{
{ label: "Security Group", value: "generic" },
{ label: "Microsoft 365 Group", value: "m365" },
{ label: "Dynamic Group", value: "dynamic" },
- { label: "Dynamic Distribution Group", value: "dynamicdistribution" },
{ label: "Distribution List", value: "distribution" },
{ label: "Mail Enabled Security Group", value: "security" },
]}
@@ -134,8 +133,8 @@ const CippAddGroupForm = (props) => {
{
{ label: "Security Group", value: "generic" },
{ label: "Microsoft 365 Group", value: "m365" },
{ label: "Dynamic Group", value: "dynamic" },
- { label: "Dynamic Distribution Group", value: "dynamicDistribution" },
{ label: "Distribution List", value: "distribution" },
{ label: "Mail Enabled Security Group", value: "security" },
]}
diff --git a/src/components/CippFormPages/CippExchangeSettingsForm.jsx b/src/components/CippFormPages/CippExchangeSettingsForm.jsx
index 0427d3d27d1f..812e28c84970 100644
--- a/src/components/CippFormPages/CippExchangeSettingsForm.jsx
+++ b/src/components/CippFormPages/CippExchangeSettingsForm.jsx
@@ -221,7 +221,7 @@ const CippExchangeSettingsForm = (props) => {
]}
/>
-
+
{
-
+
{
...other
} = props
const router = useRouter()
+ const ancestorHasGutters = useTabNavigation()?.providesGutters ?? false
+ // On mobile the tab picker directly above already reads as this page's heading whenever it
+ // shows the same text this h4 would (SAM App Roles printed its name twice in a row). The
+ // claim compares what would actually render, page-type prefix included; a row that also
+ // carries a titleButton keeps rendering, because the button has nowhere else to live.
+ const renderedTitle = hidePageType ? title : `${formPageType} - ${title}`
+ const titleClaimed = useTitleClaimedByTabPicker(renderedTitle) && !titleButton
//check if there are
const postCall = ApiPostCall({
datafromUrl: true,
@@ -135,19 +143,29 @@ const CippFormPage = (props) => {
flexGrow: 1,
}}
>
-
+
- {!hideTitle && (
+ {!hideTitle && !titleClaimed && (
-
{!hidePageType && <>{formPageType} - >}
{title}
{titleButton && titleButton}
-
+
)}
@@ -160,7 +178,16 @@ const CippFormPage = (props) => {
{!hideSubmit && (
-
+ {/* Stacked full-width on phones: Submit is the primary action of the whole
+ page and shouldn't be a narrow target crowded by the extra buttons. */}
+
{addedButtons && addedButtons}
{
{addedConditions.map((condition, index) => (
-
+
{
required={true}
/>
-
+
{
disableClearable={true}
/>
-
+
{
placeholder="*admin*"
/>
-
+
handleRemoveCondition(index)}
color="error"
diff --git a/src/components/CippIntegrations/CippIntegrationSettings.jsx b/src/components/CippIntegrations/CippIntegrationSettings.jsx
index 43ff9efbfed4..7c921741035a 100644
--- a/src/components/CippIntegrations/CippIntegrationSettings.jsx
+++ b/src/components/CippIntegrations/CippIntegrationSettings.jsx
@@ -97,9 +97,10 @@ const CippIntegrationSettings = ({ children }) => {
};
// Halo returns an explanatory row with an id of -1 when it has nothing real to offer ("no SLA
- // attached", "select a ticket type first"). It's there to be read, not picked - without this
- // it can be selected and saved as if it were a priority or an outcome.
- const isPlaceholderOption = (option) => option?.value === -1;
+ // attached", "select a ticket type first"); PWPush's account placeholders use an empty id.
+ // These rows are there to be read, not picked - without this they can be selected and saved as
+ // if they were real settings, and a saved PWPush placeholder breaks every push.
+ const isPlaceholderOption = (option) => option?.value === -1 || option?.value === "";
// Existing configs can already hold one of those rows from before it was blocked, and it would
// otherwise sit there looking like a real setting. Drop it so the field reads as unset.
diff --git a/src/components/CippPdf/CippBrandingReportPreview.jsx b/src/components/CippPdf/CippBrandingReportPreview.jsx
index dbdb3f5e0495..c22c489426ea 100644
--- a/src/components/CippPdf/CippBrandingReportPreview.jsx
+++ b/src/components/CippPdf/CippBrandingReportPreview.jsx
@@ -1,5 +1,5 @@
import { useMemo } from 'react'
-import { PDFViewer } from '@react-pdf/renderer'
+import { CippPdfPreview } from './CippPdfPreview'
import { ExecutiveReportDocument } from '../ExecutiveReportButton'
import { ShadowAIReportDocument } from '../ShadowAIReportButton'
import { BECRemediationReportDocument } from '../BECRemediationReportButton'
@@ -105,9 +105,15 @@ const CippBrandingReportPreview = ({ reportType = 'executive', brandingSettings
)
return (
-
+
{document}
-
+
)
}
diff --git a/src/components/CippPdf/CippPdfPreview.jsx b/src/components/CippPdf/CippPdfPreview.jsx
new file mode 100644
index 000000000000..85615bdcc874
--- /dev/null
+++ b/src/components/CippPdf/CippPdfPreview.jsx
@@ -0,0 +1,140 @@
+import { Box, Button, CircularProgress, Stack, Typography } from '@mui/material'
+import { Download, OpenInNew, PictureAsPdf } from '@mui/icons-material'
+import { PDFViewer, usePDF } from '@react-pdf/renderer'
+import { useIsMobileLayout } from '../../hooks/use-breakpoint'
+
+const formatSize = (bytes) => {
+ if (!bytes && bytes !== 0) return null
+ if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
+}
+
+/**
+ * The mobile half. `PDFViewer` is an iframe pointed at a blob URL, and iOS Safari renders a
+ * PDF in an iframe as a fixed first-page preview: it does not scroll, at any iframe height.
+ * No amount of CSS fixes that, so below md we stop pretending to embed the document and hand
+ * it to the platform viewer, which scrolls, pinch-zooms, shares and prints.
+ *
+ * Both actions are real anchors rather than window.open in a click handler — a programmatic
+ * open from an async callback is what mobile popup blockers exist to stop.
+ */
+const MobileHandoff = ({ document, fileName, title, showDownload }) => {
+ const [instance] = usePDF({ document })
+
+ if (instance.loading) {
+ return (
+
+
+
+ Building report…
+
+
+ )
+ }
+
+ if (instance.error || !instance.url) {
+ return (
+
+ Report could not be generated
+
+ {instance.error ? String(instance.error) : 'No document was produced.'}
+
+
+ )
+ }
+
+ const size = formatSize(instance.blob?.size)
+
+ return (
+
+
+
+
+
+
+
+ {title ?? 'Report'}
+
+ {size && (
+
+ PDF · {size}
+
+ )}
+
+
+
+ }
+ sx={{ minHeight: 44 }}
+ >
+ Open report
+
+ {/* Off by default: six of the eight hosts already put a Download in their dialog
+ actions, and two of them side by side is what this looked like on a phone. */}
+ {showDownload && (
+ }
+ sx={{ minHeight: 44 }}
+ >
+ Download
+
+ )}
+
+
+ )
+}
+
+/**
+ * Drop-in for ``: identical on desktop, a platform handoff below md.
+ *
+ * `title` labels the card and `fileName` names the download; both are mobile-only, as is
+ * `showDownload` — pass it only where the host has no download action of its own. `viewerKey`
+ * is applied to the desktop iframe alone: one caller remounts it per render to dodge a
+ * react-pdf error, and doing that on mobile would rebuild the blob every render.
+ */
+export const CippPdfPreview = (props) => {
+ const { children, fileName, title, viewerKey, showDownload = false, ...viewerProps } = props
+ const isMobile = useIsMobileLayout()
+
+ if (isMobile) {
+ return (
+
+ )
+ }
+
+ return (
+
+ {children}
+
+ )
+}
+
+export default CippPdfPreview
diff --git a/src/components/CippPdf/PermissionsReportButton.jsx b/src/components/CippPdf/PermissionsReportButton.jsx
index 6be690765885..9256f8d7b78f 100644
--- a/src/components/CippPdf/PermissionsReportButton.jsx
+++ b/src/components/CippPdf/PermissionsReportButton.jsx
@@ -12,7 +12,8 @@ import {
Typography,
} from '@mui/material'
import { Close, Download, PictureAsPdf } from '@mui/icons-material'
-import { PDFViewer, PDFDownloadLink } from '@react-pdf/renderer'
+import { PDFDownloadLink } from '@react-pdf/renderer'
+import { CippPdfPreview } from './CippPdfPreview'
import {
AlertBox,
Bold,
@@ -226,7 +227,7 @@ export const PermissionsReportDocument = ({
/>
>
) : (
-
+
No site or library grants access to Everyone, Everyone except external users, or All
Users.
@@ -261,7 +262,7 @@ export const PermissionsReportDocument = ({
/>
>
) : (
-
+
No guest or external identity holds a permission on a scanned site or library.
)}
@@ -301,7 +302,7 @@ export const PermissionsReportDocument = ({
/>
>
) : (
-
+
No user or directory group holds Full Control outside a site's Owners group.
)}
@@ -321,7 +322,7 @@ export const PermissionsReportDocument = ({
whether each detachment was intentional and is still needed.
) : (
-
+
Every scanned library takes its permissions from its site, so site-level access
management covers them all.
@@ -476,9 +477,14 @@ export const PermissionsReportButton = ({ permissionsData, tenantName }) => {
{dialogOpen && (
-
+
{documentNode}
-
+
)}
diff --git a/src/components/CippPdf/ReportDocument.jsx b/src/components/CippPdf/ReportDocument.jsx
index c9f104aad7ba..f5810cd1eaeb 100644
--- a/src/components/CippPdf/ReportDocument.jsx
+++ b/src/components/CippPdf/ReportDocument.jsx
@@ -1,6 +1,6 @@
import { Document } from '@react-pdf/renderer'
import { ReportProvider } from './reportContext'
-import { createReportTheme } from './reportTheme'
+import { applyFooterText, createReportTheme } from './reportTheme'
import { createReportStyles, DEFAULT_PAGE_SETUP } from './reportPdfStyles'
import { CoverPage } from './reportPdfPrimitives'
import { resolveCoverImage } from './resolveCoverImage'
@@ -55,8 +55,7 @@ export const ReportDocument = ({
// The report's own footer wording, used when branding configures none.
footerLabel,
- // Resolved CIPP variables, from `useReportVariables`. Without them a footer configured with
- // `%cippurl%` or a custom variable ships with the token still written in it.
+ // Resolved CIPP variables, from `useReportVariables`.
variables: cippVariables,
size = DEFAULT_PAGE_SETUP.size,
@@ -73,10 +72,8 @@ export const ReportDocument = ({
generatedOn ??
new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
- // What every `%variable%` resolves to anywhere in this report. CIPP's own come first and the
- // report's three override them: `%reportname%` and `%reportdate%` exist nowhere else, and this
- // report's subject is the authority on `%tenantname%` — it still resolves before the fetch lands,
- // which a footer that has always worked should not have to wait for.
+ // What every `%variable%` resolves to in this report. The report's own three override CIPP's,
+ // so `%tenantname%` still resolves before the variables fetch lands.
const variables = {
...cippVariables,
tenantname: tenantName || 'Organization',
@@ -86,6 +83,14 @@ export const ReportDocument = ({
const context = { theme, styles, variables, logo, footerLabel, size, orientation, date }
+ // Branding's cover note wins; a report's own wording is the fallback. Leave the prop undefined
+ // when neither is set so CoverPage's default confidentiality line still appears. Variables are
+ // filled here so a configured `%tenantname%` note resolves the same way the page footer does.
+ const coverNoteTemplate = theme.coverFooterText || coverFooterNote
+ const coverNote = coverNoteTemplate
+ ? applyFooterText(coverNoteTemplate, variables)
+ : undefined
+
return (
@@ -106,8 +111,7 @@ export const ReportDocument = ({
// Naming the client on the cover is what makes it a client report. Every report wanted
// it and each one printed it slightly differently; `coverTenant={false}` opts out.
tenantName={coverTenant === false ? null : coverTenant || tenantName}
- // Branding's cover note wins; a report's own wording is the fallback.
- footerNote={theme.coverFooterText || coverFooterNote}
+ footerNote={coverNote}
>
{coverMeta}
diff --git a/src/components/CippPdf/SharingReportButton.jsx b/src/components/CippPdf/SharingReportButton.jsx
index 3ff51601b3cf..1c09a19fdf93 100644
--- a/src/components/CippPdf/SharingReportButton.jsx
+++ b/src/components/CippPdf/SharingReportButton.jsx
@@ -12,7 +12,8 @@ import {
Typography,
} from '@mui/material'
import { Close, Download, PictureAsPdf } from '@mui/icons-material'
-import { PDFViewer, PDFDownloadLink } from '@react-pdf/renderer'
+import { PDFDownloadLink } from '@react-pdf/renderer'
+import { CippPdfPreview } from './CippPdfPreview'
import {
AlertBox,
Bold,
@@ -211,7 +212,7 @@ export const SharingReportDocument = ({
/>
>
) : (
-
+
No anonymous link grants write access.
)}
@@ -246,7 +247,7 @@ export const SharingReportDocument = ({
/>
>
) : (
-
+
Every anonymous link has an expiry date set.
)}
@@ -284,7 +285,7 @@ export const SharingReportDocument = ({
/>
>
) : (
-
+
External and anonymous shares point at individual files rather than folders.
)}
@@ -316,7 +317,7 @@ export const SharingReportDocument = ({
/>
>
) : (
-
+
Nothing has been shared with an identity outside the organisation.
)}
@@ -467,9 +468,14 @@ export const SharingReportButton = ({ sharingData, tenantName }) => {
{dialogOpen && (
-
+
{documentNode}
-
+
)}
diff --git a/src/components/CippPdf/index.js b/src/components/CippPdf/index.js
index 71468d969335..fbd7dba3192f 100644
--- a/src/components/CippPdf/index.js
+++ b/src/components/CippPdf/index.js
@@ -14,6 +14,10 @@ export {
REPORT_COLOURS,
REPORT_SERIES_SEMANTIC,
applyReportVariables,
+ applyFooterText,
+ applyWatermarkText,
+ FOOTER_MAX_LENGTH,
+ WATERMARK_MAX_LENGTH,
REPORT_COLOUR_ROLES,
asReportTheme,
buildPalette,
diff --git a/src/components/CippPdf/previewSampleData.js b/src/components/CippPdf/previewSampleData.js
index 146556bba968..a021b7bb7e8c 100644
--- a/src/components/CippPdf/previewSampleData.js
+++ b/src/components/CippPdf/previewSampleData.js
@@ -79,6 +79,18 @@ export const SAMPLE_EXECUTIVE = {
isEncrypted: true,
lastSyncDateTime: '2026-08-04T21:30:00Z',
},
+ // A Windows 365 Cloud PC: isEncrypted is false (no BitLocker) but the disk is
+ // platform-encrypted by Azure, so the report counts it as encrypted.
+ {
+ deviceName: 'CPC-SAMPLE-005',
+ operatingSystem: 'Windows',
+ complianceState: 'compliant',
+ isEncrypted: false,
+ deviceType: 'cloudPC',
+ model: 'Cloud PC Enterprise 2vCPU/8GB/128GB',
+ manufacturer: 'Microsoft Corporation',
+ lastSyncDateTime: '2026-08-05T08:20:00Z',
+ },
],
// Also a plain array — `conditionalAccessData?.data?.Results` in the real report.
conditionalAccessData: [
@@ -350,22 +362,204 @@ export const SAMPLE_SHADOW_AI = {
],
}
-/** BEC remediation report. */
+/** BEC remediation report. Field shapes mirror the real Push-BECRun payload so the preview
+ * renders every report section with plausible values rather than 'Unknown' placeholders. */
export const SAMPLE_BEC = {
userData: { displayName: 'Sample User', userPrincipalName: 'sample.user@example.com' },
becData: {
ExtractedAt: '2026-08-05T09:00:00Z',
- ExtractResult: 'Completed',
- NewRules: [{ Name: 'Sample forwarding rule', MoveToFolder: 'RSS Feeds' }],
- InboxRuleChanges: [{ Name: 'Sample rule change' }],
- NewUsers: [],
- AddedApps: [{ DisplayName: 'Sample OAuth app' }],
- MailboxPermissionChanges: [{ Grantee: 'sample.other@example.com' }],
- MFADevices: [{ Device: 'Sample phone' }],
- ChangedPasswords: [{ User: 'sample.user@example.com' }],
- TrustedSenders: [],
- BlockedSenders: [],
- SafelistChanges: [],
+ ExtractResult: 'Successfully extracted logs from auditlog',
+ AnalysisWindowDays: 7,
+ NewRules: [
+ {
+ Name: 'Sample forwarding rule',
+ Description: 'Move messages from billing@example.com to folder RSS Feeds',
+ MoveToFolder: 'RSS Feeds',
+ RecentlyChanged: true,
+ },
+ ],
+ InboxRuleChanges: [
+ {
+ Operation: 'New-InboxRule',
+ UserKey: 'sample.user@example.com',
+ RuleName: 'Sample forwarding rule',
+ Parameters: 'MoveToFolder=RSS Feeds; MarkAsRead=True',
+ Date: '2026-08-03T11:24:00Z',
+ ClientIP: '203.0.113.10',
+ Country: 'NG',
+ City: 'Lagos',
+ ForeignLocation: true,
+ },
+ ],
+ NewUsers: [
+ {
+ displayName: 'Sample Contractor',
+ userPrincipalName: 'sample.contractor@example.com',
+ createdDateTime: '2026-08-02T08:00:00Z',
+ },
+ ],
+ AddedApps: [
+ {
+ displayName: 'Sample OAuth app',
+ appId: '00000000-0000-0000-0000-000000000001',
+ publisher: 'Sample Publisher',
+ createdDateTime: '2026-08-01T10:00:00Z',
+ MaliciousMatch: null,
+ },
+ ],
+ MaliciousSPs: [
+ {
+ displayName: 'Sample Mail Sync Tool',
+ appId: '00000000-0000-0000-0000-000000000002',
+ accountEnabled: true,
+ createdDateTime: '2026-07-30T09:30:00Z',
+ CatalogName: 'Sample Mail Sync Tool',
+ Categories: ['Mailbox exfiltration', 'Business Email Compromise'],
+ Description: 'Sample catalog entry used for preview data.',
+ },
+ ],
+ MailboxPermissionChanges: [
+ {
+ Operation: 'Add-MailboxPermission',
+ UserKey: 'admin@example.com',
+ ObjectId: 'sample.user@example.com',
+ Permissions: 'FullAccess',
+ TargetsSuspect: true,
+ },
+ ],
+ SentMessages: [
+ {
+ MessageTraceId: '00000000-0000-0000-0000-000000000003',
+ Status: 'Delivered',
+ Subject: 'Sample invoice',
+ RecipientAddress: 'supplier@example.net',
+ Received: '2026-08-04 15:02:11Z',
+ FromIP: '203.0.113.10',
+ Country: 'NG',
+ City: 'Lagos',
+ ForeignLocation: true,
+ },
+ ],
+ SentMessageAnalysis: {
+ TotalMessages: 47,
+ TotalRecipients: 212,
+ RepeatedSubjects: [
+ {
+ Subject: 'Sample invoice',
+ MessageCount: 38,
+ RecipientCount: 190,
+ FirstSent: '2026-08-04 14:55:00Z',
+ LastSent: '2026-08-04 15:20:00Z',
+ Flagged: true,
+ },
+ ],
+ FlaggedSubjectCount: 1,
+ Bursts: [
+ {
+ WindowStart: '2026-08-04 15:00:00Z',
+ WindowMinutes: 10,
+ MessageCount: 31,
+ RecipientCount: 160,
+ TopSubject: 'Sample invoice',
+ },
+ ],
+ Flagged: true,
+ },
+ MFADevices: [
+ {
+ '@odata.type': '#microsoft.graph.microsoftAuthenticatorAuthenticationMethod',
+ displayName: 'Sample phone',
+ createdDateTime: '2026-08-03T12:00:00Z',
+ },
+ ],
+ ChangedPasswords: [
+ {
+ displayName: 'Sample User',
+ userPrincipalName: 'sample.user@example.com',
+ lastPasswordChangeDateTime: '2026-08-03T12:05:00Z',
+ },
+ ],
+ TrustedSenders: ['trusted@example.net', 'example-partner.com'],
+ BlockedSenders: ['security-alerts@example.org'],
+ SafelistChanges: [
+ {
+ Operation: 'Set-MailboxJunkEmailConfiguration',
+ UserKey: 'sample.user@example.com',
+ Date: '2026-08-03T11:30:00Z',
+ ClientIP: '203.0.113.10',
+ Country: 'NG',
+ City: 'Lagos',
+ ForeignLocation: true,
+ Trusted: ['attacker-domain.example'],
+ Blocked: null,
+ },
+ ],
+ SharingChanges: [
+ {
+ Operation: 'AnonymousLinkCreated',
+ UserKey: 'sample.user@example.com',
+ Date: '2026-08-04T10:15:00Z',
+ Workload: 'OneDrive',
+ FileName: 'Payroll Q3.xlsx',
+ ItemUrl: 'https://example-my.sharepoint.com/personal/sample_user/Documents/Payroll Q3.xlsx',
+ Target: null,
+ TargetType: null,
+ ClientIP: '203.0.113.10',
+ Country: 'NG',
+ City: 'Lagos',
+ ForeignLocation: true,
+ },
+ ],
+ IntuneDevices: [
+ {
+ id: '00000000-0000-0000-0000-000000000004',
+ deviceName: 'SAMPLE-VM01',
+ operatingSystem: 'Windows',
+ osVersion: '10.0.26100',
+ complianceState: 'noncompliant',
+ enrolledDateTime: '2026-08-03T13:00:00Z',
+ lastSyncDateTime: '2026-08-05T08:00:00Z',
+ deviceEnrollmentType: 'windowsAzureADJoin',
+ serialNumber: 'SAMPLE1234',
+ },
+ ],
+ SuspectUserSignIns: [
+ {
+ CreatedDateTime: '2026-08-04T22:14:00Z',
+ AppDisplayName: 'Office 365 Exchange Online',
+ ClientAppUsed: 'Browser',
+ Status: 'Success',
+ IPAddress: '203.0.113.10',
+ Country: 'NG',
+ City: 'Lagos',
+ ForeignLocation: true,
+ },
+ {
+ CreatedDateTime: '2026-08-04T09:02:00Z',
+ AppDisplayName: 'Microsoft Teams',
+ ClientAppUsed: 'Mobile Apps and Desktop clients',
+ Status: 'Success',
+ IPAddress: '198.51.100.24',
+ Country: 'US',
+ City: 'Seattle',
+ ForeignLocation: false,
+ },
+ ],
+ LocationAnalysis: {
+ UsageLocation: 'US',
+ UserRegisteredCountry: 'United States',
+ SignInCountries: [
+ { Country: 'US', Count: 41 },
+ { Country: 'NG', Count: 9 },
+ ],
+ ForeignSignInCount: 9,
+ ForeignSuccessfulSignInCount: 8,
+ ForeignRuleChangeCount: 1,
+ ForeignSafelistChangeCount: 1,
+ ForeignSharingChangeCount: 1,
+ ForeignSentMessageCount: 1,
+ Note: null,
+ },
},
}
diff --git a/src/components/CippPdf/reportPdfPrimitives.jsx b/src/components/CippPdf/reportPdfPrimitives.jsx
index 86b292623f1e..94049897534a 100644
--- a/src/components/CippPdf/reportPdfPrimitives.jsx
+++ b/src/components/CippPdf/reportPdfPrimitives.jsx
@@ -1,6 +1,6 @@
import { Children } from 'react'
import { Text, View, Image, Page } from '@react-pdf/renderer'
-import { REPORT_COLOURS, applyReportVariables } from './reportTheme'
+import { REPORT_COLOURS, applyFooterText, applyWatermarkText } from './reportTheme'
import { useReport, useReportStyles } from './reportContext'
import { DEFAULT_PAGE_SETUP, TABLE_ROW_PADDING, contentWidth } from './reportPdfStyles'
import { wrapLongTokens } from './measureText'
@@ -124,7 +124,7 @@ export const ContentPage = ({ title, subtitle, children, ...props }) => {
*/
export const PageFooter = ({ styles, label, theme, variables }) => {
const templated = theme?.footer?.enabled
- ? applyReportVariables(theme.footer.template, variables)
+ ? applyFooterText(theme.footer.template, variables)
: ''
// Configured branding wins over the report's own label. The reverse — which this did at first —
// meant every report that passed a label silently ignored the footer text an MSP had set, which
@@ -173,16 +173,21 @@ export const ReportPage = ({
}) => (
{children}
- {/* Last, so it paints over the content rather than under it. Underneath, anything with a solid
- background — a chart card, a stat tile, a table header — hid it completely, which is how a
- page that did carry a watermark still looked like it did not. At 8% it reads as a wash over
- the page and leaves everything below it legible. */}
+ {/* Last, so it paints over the content. Underneath, any solid background hides it entirely. */}
)
-export const Watermark = ({ styles, theme, text, onDark = false }) => {
- const value = text ?? (theme?.watermark?.enabled ? theme.watermark.text : '')
+/**
+ * Diagonal mark drawn over every page. Same `%variable%` substitution as the footer — branding
+ * stores a template (e.g. `%tenantname%`), and the report fills it from the surrounding context.
+ * The 40-character ceiling is applied to the *resolved* string, after variables expand.
+ */
+export const Watermark = ({ styles, theme, text, variables: variablesProp, onDark = false }) => {
+ const report = useReport()
+ const variables = variablesProp ?? report.variables
+ const template = text ?? (theme?.watermark?.enabled ? theme.watermark.text : '')
+ const value = template ? applyWatermarkText(template, variables) : ''
if (!value) return null
return (
diff --git a/src/components/CippPdf/reportTheme.js b/src/components/CippPdf/reportTheme.js
index 6cc5439c2c13..c2443b55d26a 100644
--- a/src/components/CippPdf/reportTheme.js
+++ b/src/components/CippPdf/reportTheme.js
@@ -169,6 +169,12 @@ const buildSeries = (primary, secondary) => {
const DEFAULT_FOOTER_TEMPLATE = ''
const DEFAULT_WATERMARK_TEXT = ''
+/** Hard ceiling for page/cover footer text — applied after `%variable%` substitution. */
+export const FOOTER_MAX_LENGTH = 200
+
+/** Hard ceiling for the mark drawn on the page — applied after `%variable%` substitution. */
+export const WATERMARK_MAX_LENGTH = 40
+
/**
* The parts of a report that can be coloured independently.
*
@@ -300,10 +306,8 @@ export const buildPalette = (branding, { primary, secondary }) => {
* footer might want — `%tenantname%` foremost — is already a CIPP variable, so it is not restated
* here. The `report` prefix keeps these clear of the reserved names in Get-CIPPTextReplacement.
*
- * A PDF is rendered in the browser, so Get-CIPPTextReplacement never sees this text and cannot fill
- * CIPP's own variables in it. `useReportVariables` reads their resolved values back out of
- * ListCustomVariables and hands them to the report, which is what makes `%cippurl%` in a footer
- * print the URL rather than the word. Being a CIPP variable is about where it is documented and
+ * A PDF renders in the browser, so Get-CIPPTextReplacement never sees this text. `useReportVariables`
+ * supplies the resolved values instead. Being a CIPP variable is about where it is documented and
* offered, not about who substitutes it.
*/
export const REPORT_VARIABLES = [
@@ -318,9 +322,8 @@ export const REPORT_VARIABLES = [
* and an unknown token is left as written rather than blanked — that is what tells whoever
* configured it that they mistyped, instead of silently swallowing it.
*
- * This runs in the browser because that is where the PDF is rendered, so it is given the values
- * rather than looking them up: the report's own tokens plus whatever `useReportVariables` resolved
- * out of CIPP for the tenant.
+ * Given the values rather than looking them up: the report's own tokens plus whatever
+ * `useReportVariables` resolved for the tenant.
*/
export const applyReportVariables = (template, variables = {}) => {
if (!template) return ''
@@ -336,6 +339,23 @@ export const applyReportVariables = (template, variables = {}) => {
})
}
+/**
+ * Resolve a watermark template and enforce the on-page length ceiling.
+ *
+ * The branding field stores a template (and rejects templates over the same limit). Tenant names
+ * and other variables can still expand past it at render time — that is when the ceiling is
+ * applied, so a long `%tenantname%` cannot spill a mark across the whole page.
+ */
+export const applyWatermarkText = (template, variables = {}) =>
+ applyReportVariables(template, variables).slice(0, WATERMARK_MAX_LENGTH)
+
+/**
+ * Resolve page-footer / cover-note text and enforce the length ceiling after substitution.
+ * Same reason as the watermark: a long `%tenantname%` must not blow past the stored limit.
+ */
+export const applyFooterText = (template, variables = {}) =>
+ applyReportVariables(template, variables).slice(0, FOOTER_MAX_LENGTH)
+
/**
* Build the theme a report renders against.
*
diff --git a/src/components/CippPdf/useBrandingSettings.js b/src/components/CippPdf/useBrandingSettings.js
index 45006095b7ba..ff62ffded424 100644
--- a/src/components/CippPdf/useBrandingSettings.js
+++ b/src/components/CippPdf/useBrandingSettings.js
@@ -3,11 +3,8 @@ import { ApiGetCall } from '../../api/ApiCall'
import { DEFAULT_COVER_STOCK } from './resolveCoverImage'
/**
- * The branding a report is drawn with, before any preset is applied.
- *
- * What every consumer sees when branding has not loaded yet — or cannot be read at all. A report
- * rendered in CIPP's own colours is worth far more than one that fails to render, so nothing here
- * waits on the fetch.
+ * Branding used before the fetch lands, or when it cannot be read at all. Reports render in
+ * CIPP's own colours rather than waiting.
*/
export const DEFAULT_BRANDING = Object.freeze({
colour: '#F77F00',
@@ -32,32 +29,20 @@ export const DEFAULT_BRANDING = Object.freeze({
})
/**
- * The react-query keys branding is cached under. Invalidate both after any branding write —
- * `relatedQueryKeys: ['BrandingSettings*']` covers them with one entry.
- *
- * Two keys because they are two different payloads: the gallery form carries every uploaded logo
- * and cover inline, which is megabytes, and only the settings page needs it.
+ * Cache keys for the two payloads: with and without the upload galleries. Invalidate both after a
+ * branding write with `relatedQueryKeys: ['BrandingSettings*']`.
*/
export const BRANDING_QUERY_KEY = 'BrandingSettings'
export const BRANDING_GALLERY_QUERY_KEY = 'BrandingSettings-gallery'
/**
- * Read the report branding.
- *
- * Branding used to live on `useSettings().customBranding`, filled in by ListUserSettings. That put
- * every uploaded cover image — inline base64 data URLs, megabytes of them — into a response fetched
- * on every page load, for the benefit of the handful of screens that draw a PDF. It also meant the
- * branding a report used and the branding the settings page was editing were the same mutable blob
- * of client state, kept in step by an effect.
- *
- * Now it is a request, made by the components that need it, cached by react-query and shared
- * between them. `relatedQueryKeys: [BRANDING_QUERY_KEY]` on a write is what refreshes it.
+ * Read the report branding. Replaces `useSettings().customBranding`, which carried inline images
+ * on every page load and kept report and settings state in the same mutable blob.
*/
export const useBrandingSettings = ({ waiting = true, includeGallery = false } = {}) => {
const branding = ApiGetCall({
url: '/api/ListBrandingSettings',
- // Only the settings page asks for the galleries. A report needs the logo and cover that are
- // selected, and those come back either way.
+ // Only the settings page needs the galleries; the selected logo and cover come back either way.
data: includeGallery ? { includeGallery: true } : undefined,
queryKey: includeGallery ? BRANDING_GALLERY_QUERY_KEY : BRANDING_QUERY_KEY,
waiting,
@@ -65,7 +50,7 @@ export const useBrandingSettings = ({ waiting = true, includeGallery = false } =
return useMemo(() => {
const data = branding.data
- // The endpoint answers 200 with no body when branding cannot be read, so the app still renders.
+ // The endpoint answers 200 with no body when branding cannot be read.
if (!data || typeof data !== 'object' || Array.isArray(data)) return DEFAULT_BRANDING
return data
}, [branding.data])
diff --git a/src/components/CippPdf/useReportVariables.js b/src/components/CippPdf/useReportVariables.js
index b0350f70df55..8d19f6979037 100644
--- a/src/components/CippPdf/useReportVariables.js
+++ b/src/components/CippPdf/useReportVariables.js
@@ -6,20 +6,12 @@ import { useSettings } from '../../hooks/use-settings'
const EMPTY = {}
/**
- * The resolved values of every CIPP variable, for substitution into report footers and watermarks.
+ * Resolved values of every CIPP variable, for substitution into report footers and watermarks by
+ * `applyReportVariables`. A PDF renders in the browser and never passes through
+ * Get-CIPPTextReplacement, so the values are read from ListCustomVariables instead.
*
- * A report's footer is written by an operator on the branding page, where the `%` picker offers the
- * whole CIPP variable vocabulary — `%cippurl%`, `%tenantid%`, custom variables, all of it. Those are
- * normally filled in by Get-CIPPTextReplacement on the server, but a PDF is rendered in the browser
- * and never passes through it, so a footer that used anything beyond the report's own tokens shipped
- * with the token still written in it.
- *
- * This is the missing half: the values come from ListCustomVariables, which resolves them for the
- * tenant, and `applyReportVariables` does the substitution at render time.
- *
- * Fetched here rather than inside the document because a report is rendered by react-pdf's own
- * reconciler, outside the React tree — there is no query client in there to hook into. The values
- * have to arrive as data.
+ * Fetched by the caller rather than inside the document: react-pdf renders through its own
+ * reconciler, outside the React tree, where there is no query client.
*/
export const useReportVariables = (tenantFilter) => {
const currentTenant = useSettings()?.currentTenant
@@ -42,9 +34,8 @@ export const useReportVariables = (tenantFilter) => {
const resolved = {}
for (const variable of results) {
- // A variable with no value is one CIPP cannot fill — the system tokens expanded on an
- // endpoint, mainly. Leaving it out means the token stays written in the footer, which is what
- // tells whoever configured it that it does not resolve here.
+ // Valueless variables (mostly system tokens, expanded on an endpoint) are left out, so the
+ // token stays written in the footer rather than resolving to nothing.
if (variable?.Name && variable.Value !== null && variable.Value !== undefined && variable.Value !== '') {
resolved[variable.Name] = variable.Value
}
diff --git a/src/components/CippSettings/CippAppServiceDomains.jsx b/src/components/CippSettings/CippAppServiceDomains.jsx
index 480aa73cf450..4f7c7b41c3ec 100644
--- a/src/components/CippSettings/CippAppServiceDomains.jsx
+++ b/src/components/CippSettings/CippAppServiceDomains.jsx
@@ -85,12 +85,12 @@ const HOSTNAME_REGEX = /^(\*\.)?([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i;
const InfoRow = ({ label, value, copy = true }) => (
-
+
{label}
-
+
{value || "—"}
diff --git a/src/components/CippSettings/CippBrandingCoverPreview.jsx b/src/components/CippSettings/CippBrandingCoverPreview.jsx
index a4c016ad2b14..3dc4b86c4a8f 100644
--- a/src/components/CippSettings/CippBrandingCoverPreview.jsx
+++ b/src/components/CippSettings/CippBrandingCoverPreview.jsx
@@ -1,7 +1,7 @@
import { Box, Typography } from "@mui/material";
import { resolveCoverImage } from "../CippPdf/resolveCoverImage";
import { createReportStyles } from "../CippPdf/reportPdfStyles";
-import { createReportTheme } from "../CippPdf/reportTheme";
+import { applyFooterText, applyWatermarkText, createReportTheme } from "../CippPdf/reportTheme";
import {
SAMPLE_BEC,
SAMPLE_PERMISSIONS,
@@ -37,6 +37,8 @@ export const REPORT_COVER_PRESETS = [
{
id: "executive",
label: "Executive Report",
+ // Must match `reportName` on ExecutiveReportDocument — cover-mock `%reportname%` uses this.
+ reportName: "Executive Summary",
coverLabel: "Security Assessment",
title: "Executive",
accent: "Summary",
@@ -48,6 +50,7 @@ export const REPORT_COVER_PRESETS = [
{
id: "shadowAI",
label: "Shadow AI Report",
+ reportName: "Shadow AI Report",
coverLabel: "AI Risk Assessment",
title: "Shadow AI",
accent: "Report",
@@ -60,6 +63,7 @@ export const REPORT_COVER_PRESETS = [
{
id: "bec",
label: "BEC Remediation",
+ reportName: "BEC Analysis Report",
coverLabel: "Security Incident Report",
title: "BEC Compromise",
accent: "Analysis",
@@ -74,6 +78,7 @@ export const REPORT_COVER_PRESETS = [
{
id: "sharing",
label: "Sharing Report",
+ reportName: "Sharing Report",
coverLabel: "Data Sharing Review",
title: "Sharing",
accent: "Report",
@@ -88,6 +93,7 @@ export const REPORT_COVER_PRESETS = [
{
id: "permissions",
label: "Permissions Report",
+ reportName: "Permissions Report",
coverLabel: "Access Review",
title: "Permissions",
accent: "Report",
@@ -102,6 +108,8 @@ export const REPORT_COVER_PRESETS = [
// the report builder, so it belongs after the reports that are the same every time.
id: "reportBuilder",
label: "Report Builder",
+ // Matches the sample template name used by CippBrandingReportPreview for this report type.
+ reportName: "Quarterly Security Review",
coverLabel: "Assessment Report",
title: "Custom",
accent: "Report",
@@ -152,6 +160,18 @@ const CippBrandingCoverPreview = ({
month: "long",
day: "numeric",
});
+ // Same substitution + length ceiling the PDF applies — without it, typing %tenantname% in
+ // branding shows the token literally in this mock while real reports resolve it.
+ const previewVariables = {
+ tenantname: SAMPLE_TENANT_NAME,
+ reportname: preset.reportName,
+ reportdate: currentDate,
+ };
+ const watermarkLabel = applyWatermarkText(theme.watermark.text, previewVariables);
+ const coverFooterLabel = applyFooterText(
+ theme.coverFooterText || preset.footer,
+ previewVariables
+ );
return (
- {theme.watermark.text}
+ {watermarkLabel}
)}
@@ -339,7 +359,7 @@ const CippBrandingCoverPreview = ({
}}
>
{/* A configured cover note replaces the report's own wording, exactly as the PDF does. */}
- {theme.coverFooterText || preset.footer}
+ {coverFooterLabel}
diff --git a/src/components/CippSettings/CippBrandingSettings.jsx b/src/components/CippSettings/CippBrandingSettings.jsx
index de3f9f454e69..44ca040a3b06 100644
--- a/src/components/CippSettings/CippBrandingSettings.jsx
+++ b/src/components/CippSettings/CippBrandingSettings.jsx
@@ -33,7 +33,7 @@ import {
normalizeLogoImageIds,
normalizeLogoUploads,
} from "../CippPdf/resolveCoverImage";
-import { REPORT_COLOUR_ROLES } from "../CippPdf/reportTheme";
+import { FOOTER_MAX_LENGTH, REPORT_COLOUR_ROLES, WATERMARK_MAX_LENGTH } from "../CippPdf/reportTheme";
import { BRANDING_GALLERY_QUERY_KEY } from "../CippPdf/useBrandingSettings";
import { useForm } from "react-hook-form";
@@ -78,7 +78,7 @@ const FOOTER_TOOLTIP =
"Text shown at the bottom of every report page. Type % for CIPP's variables, plus %reportname% and %reportdate% which reports add. Report templates can override this or switch it off individually.";
const WATERMARK_TOOLTIP =
- "Diagonal text drawn faintly across every page of a report, cover included — e.g. DRAFT or CONFIDENTIAL. Typing text is enough to show it; the toggle only exists to switch it off without losing the wording.";
+ "Diagonal text drawn faintly across every page of a report, cover included. Type % for CIPP's variables (e.g. %tenantname%), or a static mark such as DRAFT. Typing text is enough to show it; the toggle only exists to switch it off without losing the wording.";
const REPORT_DEFAULTS_TOOLTIP =
"Which preset each report reaches for when nothing else says otherwise. A report template with its own preset still wins over this, and this still wins over the default branding above.";
@@ -222,9 +222,8 @@ const GalleryTile = ({
const CippBrandingSettings = () => {
const settings = useSettings();
- // Read through ApiGetCall rather than useBrandingSettings so this page can see when the fetch
- // landed: the sync effect below has to run on a *new* server payload, not on every render.
- // Same url and queryKey, so it is the same cache entry every report reads.
+ // Read through ApiGetCall rather than useBrandingSettings so the sync effect below can key on
+ // when the fetch landed. Same cache entry either way.
const brandingQuery = ApiGetCall({
url: "/api/ListBrandingSettings",
data: { includeGallery: true },
@@ -415,10 +414,6 @@ const CippBrandingSettings = () => {
if (coversHydrated || logosHydrated) {
setCoversReady(true);
}
- // Branding used to be a mutable client blob on the settings object, so this had to list every
- // field that might have changed underneath it — and compare the arrays by hand, because their
- // identity changed on every render. A query has one answer to "is this a new payload from the
- // server", which is the only question this effect was ever asking.
// eslint-disable-next-line react-hooks/exhaustive-deps -- sync when server branding payload changes
}, [activePresetId, uploadPending, brandingQuery.isSuccess, brandingQuery.dataUpdatedAt]);
@@ -1417,12 +1412,12 @@ const CippBrandingSettings = () => {
name="footerText"
formControl={formControl}
placeholder="%tenantname% — prepared by Contoso IT — %reportdate%"
- helperText="Type % for variables. Reports add %reportname% and %reportdate%."
+ helperText={`Type % for variables. Reports add %reportname% and %reportdate%. After substitution, text is capped at ${FOOTER_MAX_LENGTH} characters.`}
includeSystemVariables={true}
validators={{
maxLength: {
- value: 200,
- message: "Footer text must be 200 characters or fewer",
+ value: FOOTER_MAX_LENGTH,
+ message: `Footer text must be ${FOOTER_MAX_LENGTH} characters or fewer`,
},
}}
/>
@@ -1431,17 +1426,17 @@ const CippBrandingSettings = () => {
name="coverFooterText"
label="Cover Note"
placeholder="Blank = each report's own wording"
- helperText="Replaces the confidentiality note on cover pages"
+ helperText={`Replaces the confidentiality note on cover pages. After substitution, text is capped at ${FOOTER_MAX_LENGTH} characters.`}
includeSystemVariables={true}
formControl={formControl}
validators={{
maxLength: {
- value: 200,
- message: "Cover note must be 200 characters or fewer",
+ value: FOOTER_MAX_LENGTH,
+ message: `Cover note must be ${FOOTER_MAX_LENGTH} characters or fewer`,
},
}}
/>
-
+
{
diff --git a/src/components/CippSettings/CippContainerManagement.jsx b/src/components/CippSettings/CippContainerManagement.jsx
index d4764fa06857..96a1c96a0a76 100644
--- a/src/components/CippSettings/CippContainerManagement.jsx
+++ b/src/components/CippSettings/CippContainerManagement.jsx
@@ -29,6 +29,7 @@ import { useForm, useWatch } from 'react-hook-form'
import CippFormComponent from '../CippComponents/CippFormComponent'
import CippButtonCard from '../CippCards/CippButtonCard'
import { CippInfoBar } from '../CippCards/CippInfoBar'
+import { CippDataTable } from '../CippTable/CippDataTable'
import { ApiGetCall, ApiPostCall } from '../../api/ApiCall'
import { CippApiResults } from '../CippComponents/CippApiResults'
import { useDialog } from '../../hooks/use-dialog'
@@ -543,6 +544,24 @@ export const CippContainerManagement = () => {
+
+
+ {/* Version transitions recorded at warmup - answers "when did this instance land
+ on the current build, and what was it on before?" without reading container
+ logs. Rows come newest first from the Status payload. */}
+ containerStatus.refetch()}
+ simpleColumns={[
+ 'RecordedAt',
+ 'PreviousVersion',
+ 'NewVersion',
+ 'ImageTag',
+ ]}
+ />
+
diff --git a/src/components/CippSettings/CippGDAP/CippFlowDiagram.jsx b/src/components/CippSettings/CippGDAP/CippFlowDiagram.jsx
index a6a2e7ada925..c3e2dd411a90 100644
--- a/src/components/CippSettings/CippGDAP/CippFlowDiagram.jsx
+++ b/src/components/CippSettings/CippGDAP/CippFlowDiagram.jsx
@@ -77,7 +77,7 @@ export const CippFlowDiagram = ({
)}
{node.chips && node.chips.length > 0 && (
-
+
{node.chips.map((chip, chipIndex) => (
))}
diff --git a/src/components/CippSettings/CippGDAP/CippGDAPTraceResults.jsx b/src/components/CippSettings/CippGDAP/CippGDAPTraceResults.jsx
index ff34b407388a..2d893fbe1bbe 100644
--- a/src/components/CippSettings/CippGDAP/CippGDAPTraceResults.jsx
+++ b/src/components/CippSettings/CippGDAP/CippGDAPTraceResults.jsx
@@ -468,7 +468,7 @@ export const CippGDAPTraceResults = ({ data, isLoading, error }) => {
>
Additional Roles:
-
+
{group.roles.slice(1).map((role, roleIndex) => (
{relationshipName && (
-
+
Relationship: {relationshipName}
diff --git a/src/components/CippSettings/CippGDAPResults.jsx b/src/components/CippSettings/CippGDAPResults.jsx
index 306c7451eb32..dd997acf931c 100644
--- a/src/components/CippSettings/CippGDAPResults.jsx
+++ b/src/components/CippSettings/CippGDAPResults.jsx
@@ -68,6 +68,15 @@ export const CippGDAPResults = (props) => {
};
const gdapTests = [
+ {
+ resultProperty: "GDAPIssues",
+ matchProperty: "Issue",
+ match: ".+Partner Center API.+",
+ count: 0,
+ successMessage: "Partner Center API access is granted to the SAM application",
+ failureMessage:
+ "The SAM application cannot access the Partner Center API. Click Details for more information.",
+ },
{
resultProperty: "Memberships",
matchProperty: "displayName",
@@ -143,7 +152,15 @@ export const CippGDAPResults = (props) => {
)}
{!importReport && executeCheck?.isFetching ? (
-
+
+ {[70, 85, 60, 75].map((width, index) => (
+
+
+
+
+
+ ))}
+
) : !importReport && executeCheck?.isError ? (
Failed to load GDAP check results. Please try refreshing or contact support if the issue
diff --git a/src/components/CippSettings/CippPermissionReport.jsx b/src/components/CippSettings/CippPermissionReport.jsx
index 7a7e72e74402..1d5794f77f39 100644
--- a/src/components/CippSettings/CippPermissionReport.jsx
+++ b/src/components/CippSettings/CippPermissionReport.jsx
@@ -1,12 +1,24 @@
-import { Button, Stack, SvgIcon, Tooltip } from "@mui/material";
+import {
+ Button,
+ List,
+ ListItemButton,
+ ListItemIcon,
+ ListItemText,
+ Stack,
+ SvgIcon,
+ Tooltip,
+} from "@mui/material";
import { Close, ContentPasteGo, FileDownload, FileUpload } from "@mui/icons-material";
import { ApiGetCall } from "../../api/ApiCall";
import { useDialog } from "../../hooks/use-dialog";
+import { useIsMobileLayout } from "../../hooks/use-breakpoint";
import { CippApiDialog } from "../CippComponents/CippApiDialog";
+import { CippPageActionsFab } from "../CippComponents/CippPageActionsFab";
import { useState } from "react";
export const CippPermissionReport = (props) => {
const { importReport, setImportReport } = props;
+ const isMobile = useIsMobileLayout();
const [importError, setImportError] = useState(false);
const [currentFile, setCurrentFile] = useState(null);
const createDialog = useDialog();
@@ -175,9 +187,8 @@ export const CippPermissionReport = (props) => {
}
};
- return (
+ const reportButtons = (
<>
-
{
{importError}
)}
-
+ >
+ );
+
+ return (
+ <>
+ {/* Page-level utilities: a row of contained buttons on desktop, but three of those
+ stacked full-width at 390px read as a banner wall — on mobile they ride in the
+ page-actions FAB sheet as plain list rows, uniform with every other sheet action.
+ TabbedLayout no longer puts anything in that corner, so the FAB is this page's own. */}
+ {isMobile ? (
+
+
+
+
+
+
+
+
+ {/* The sheet stays mounted (keepMounted), so the hidden input survives the
+ sheet closing while the OS file picker is up. */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {importReport && (
+ setImportReport(false)} sx={{ minHeight: 48 }}>
+
+
+
+
+
+ )}
+ {importError && (
+ setImportError(false)}
+ sx={{ minHeight: 48, color: "error.main" }}
+ >
+
+
+
+
+
+ )}
+
+
+ ) : (
+
+ {reportButtons}
+
+ )}
{
/>
)}
{!importReport && executeCheck?.isFetching ? (
-
+
+ {[70, 85, 60, 75].map((width, index) => (
+
+
+
+
+
+ ))}
+
) : !importReport && executeCheck?.isError ? (
Failed to load permission check results. Please try refreshing or contact support if the
diff --git a/src/components/CippSettings/CippRoleAddEdit.jsx b/src/components/CippSettings/CippRoleAddEdit.jsx
index 75192d394004..364dd38a70b7 100644
--- a/src/components/CippSettings/CippRoleAddEdit.jsx
+++ b/src/components/CippSettings/CippRoleAddEdit.jsx
@@ -1,9 +1,10 @@
-import React, { useEffect, useState } from "react";
+import React, { useEffect, useMemo, useState } from "react";
import {
Box,
Button,
Alert,
+ Chip,
Typography,
Accordion,
AccordionSummary,
@@ -11,6 +12,8 @@ import {
Stack,
SvgIcon,
Skeleton,
+ ToggleButton,
+ ToggleButtonGroup,
} from "@mui/material";
import { Grid } from "@mui/system";
@@ -25,6 +28,15 @@ import { InformationCircleIcon } from "@heroicons/react/24/outline";
import { CippApiResults } from "../CippComponents/CippApiResults";
import cippRoles from "../../data/cipp-roles.json";
import { GroupHeader, GroupItems } from "../CippComponents/CippAutocompleteGrouping";
+import {
+ matchPattern,
+ flattenPermissionTree,
+ expandRules,
+ rulesToFlatMap,
+ flatMapToRules,
+ validateRulePattern,
+ buildRuleSuggestions,
+} from "../../utils/permission-rules";
export const CippRoleAddEdit = ({ selectedRole }) => {
const updatePermissions = ApiPostCall({
@@ -38,6 +50,11 @@ export const CippRoleAddEdit = ({ selectedRole }) => {
const [updateDefaults, setUpdateDefaults] = useState(false);
const [baseRolePermissions, setBaseRolePermissions] = useState({});
const [isBaseRole, setIsBaseRole] = useState(false);
+ // New roles start in simple (pattern) mode; existing roles pick their mode in the
+ // reset effect based on whether their stored rules contain wildcards.
+ const [permissionMode, setPermissionMode] = useState(selectedRole ? "advanced" : "simple");
+ const [gridDiverged, setGridDiverged] = useState(false);
+ const [rulePreviewVisible, setRulePreviewVisible] = useState(false);
const formControl = useForm({
mode: "onChange",
@@ -47,6 +64,8 @@ export const CippRoleAddEdit = ({ selectedRole }) => {
BlockedEndpoints: [],
IPRange: [],
Permissions: {},
+ PermissionRulesInclude: [],
+ PermissionRulesExclude: [],
},
});
@@ -76,6 +95,20 @@ export const CippRoleAddEdit = ({ selectedRole }) => {
const selectedPermissions = useWatch({ control: formControl.control, name: "Permissions" });
const selectedEntraGroup = useWatch({ control: formControl.control, name: "EntraGroup" });
const ipRanges = useWatch({ control: formControl.control, name: "IPRange" });
+ const includeRules = useWatch({ control: formControl.control, name: "PermissionRulesInclude" });
+ const excludeRules = useWatch({ control: formControl.control, name: "PermissionRulesExclude" });
+ const baseRoleTemplate = useWatch({ control: formControl.control, name: "BaseRoleTemplate" });
+
+ // "Start from a built-in role": copy its patterns into the rule fields as an
+ // editable starting point, then clear the picker so it acts as a one-shot action.
+ useEffect(() => {
+ const roleName = baseRoleTemplate?.value;
+ if (!roleName || !cippRoles[roleName]) return;
+ const toOptions = (list) => (list || []).map((pattern) => ({ label: pattern, value: pattern }));
+ formControl.setValue("PermissionRulesInclude", toOptions(cippRoles[roleName].include));
+ formControl.setValue("PermissionRulesExclude", toOptions(cippRoles[roleName].exclude));
+ formControl.setValue("BaseRoleTemplate", null);
+ }, [baseRoleTemplate]);
const {
data: apiPermissions = [],
@@ -105,9 +138,38 @@ export const CippRoleAddEdit = ({ selectedRole }) => {
});
const tenants = pages[0] || [];
- const matchPattern = (pattern, value) => {
- const regex = new RegExp(`^${pattern.replace("*", ".*")}$`);
- return regex.test(value);
+ const permissionUniverse = useMemo(() => flattenPermissionTree(apiPermissions), [apiPermissions]);
+ const ruleSuggestions = useMemo(() => buildRuleSuggestions(apiPermissions), [apiPermissions]);
+ const currentRules = useMemo(
+ () => ({
+ Include: (includeRules || []).map((o) => o?.value || o).filter(Boolean),
+ Exclude: (excludeRules || []).map((o) => o?.value || o).filter(Boolean),
+ }),
+ [includeRules, excludeRules]
+ );
+ const ruleExpansion = useMemo(
+ () => expandRules(currentRules, permissionUniverse),
+ [currentRules, permissionUniverse]
+ );
+ // Login breaks without CIPP.Core.Read; save auto-adds it when rules miss it.
+ const coreCovered = ruleExpansion.matched.some((p) => p.startsWith("CIPP.Core."));
+
+ const handleModeChange = (_event, newMode) => {
+ if (!newMode || newMode === permissionMode) return;
+ if (newMode === "advanced") {
+ // Expand rules into the grid so the advanced view reflects the same role.
+ if (currentRules.Include.length > 0) {
+ formControl.setValue("Permissions", rulesToFlatMap(currentRules, apiPermissions));
+ }
+ setGridDiverged(false);
+ } else {
+ const rulesGrid = rulesToFlatMap(currentRules, apiPermissions);
+ const diverged =
+ currentRules.Include.length > 0 &&
+ Object.keys(rulesGrid).some((key) => (selectedPermissions?.[key] ?? null) !== rulesGrid[key]);
+ setGridDiverged(diverged);
+ }
+ setPermissionMode(newMode);
};
const getFunctionDescriptionText = (description) => {
@@ -277,6 +339,10 @@ export const CippRoleAddEdit = ({ selectedRole }) => {
value: ip,
})) || [];
+ const storedRules = currentPermissions?.PermissionRules;
+ const toRuleOptions = (list) =>
+ Array.isArray(list) ? list.map((pattern) => ({ label: pattern, value: pattern })) : [];
+
formControl.reset({
Permissions:
basePermissions && Object.keys(basePermissions).length > 0
@@ -288,7 +354,16 @@ export const CippRoleAddEdit = ({ selectedRole }) => {
BlockedEndpoints: processedBlockedEndpoints,
IPRange: processedIPRanges,
EntraGroup: currentPermissions?.EntraGroup,
+ PermissionRulesInclude: toRuleOptions(storedRules?.Include),
+ PermissionRulesExclude: toRuleOptions(storedRules?.Exclude),
});
+ if (currentPermissions) {
+ // Wildcard roles open in simple mode; migrated concrete-string roles open in
+ // the grid, which is the friendlier view of an explicit list.
+ const hasWildcards = storedRules?.Include?.some((pattern) => pattern.includes("*"));
+ setPermissionMode(hasWildcards ? "simple" : "advanced");
+ setGridDiverged(false);
+ }
}
}, [customRoleList, customRoleListSuccess, tenantsSuccess, baseRolePermissions]);
@@ -383,11 +458,28 @@ export const CippRoleAddEdit = ({ selectedRole }) => {
return ip?.value || ip;
}) || [];
+ // PermissionRules is the canonical format for both modes: simple mode sends the
+ // authored patterns, advanced mode sends concrete strings derived from the grid.
+ // Permissions stays as a flat snapshot for older backends.
+ const activeRules =
+ permissionMode === "simple"
+ ? {
+ Include:
+ coreCovered || currentRules.Include.length === 0
+ ? currentRules.Include
+ : [...currentRules.Include, "CIPP.Core.Read"],
+ Exclude: currentRules.Exclude,
+ }
+ : flatMapToRules(selectedPermissions);
+ const snapshotPermissions =
+ permissionMode === "simple" ? rulesToFlatMap(activeRules, apiPermissions) : selectedPermissions;
+
updatePermissions.mutate({
url: "/api/ExecCustomRole?Action=AddUpdate",
data: {
RoleName: values?.["RoleName"],
- Permissions: selectedPermissions,
+ Permissions: snapshotPermissions,
+ PermissionRules: activeRules,
EntraGroup: selectedEntraGroup,
AllowedTenants: processedAllowedTenants,
BlockedTenants: processedBlockedTenants,
@@ -409,14 +501,15 @@ export const CippRoleAddEdit = ({ selectedRole }) => {
return (
{obj}
-
+
setOffcanvasVisible(true)} size="sm" color="info">
@@ -510,8 +603,11 @@ export const CippRoleAddEdit = ({ selectedRole }) => {
return (
<>
-
-
+ {/* The summary pane rides beside the form only where there is room for both; below xl
+ it follows the form instead of squeezing it (the old 80%/30% flex split shrank both
+ panes at every width and pushed the summary off a phone screen entirely). */}
+
+
Role Options
@@ -788,65 +884,345 @@ export const CippRoleAddEdit = ({ selectedRole }) => {
API Permissions
{!isBaseRole && (
-
- Set All Permissions
-
-
-
+ Simple (patterns)
+ Advanced (per-category)
+
+ )}
+ {!isBaseRole && permissionMode === "simple" && (
+
+
+ Simple mode works like CIPP's built-in roles: pick what to include, then carve
+ out exclusions. Wildcards (*) match anything, so rules automatically cover new
+ features added in future CIPP releases.
+
+ {gridDiverged && (
+
+ Changes made in Advanced mode are not reflected in these patterns. Saving in
+ Simple mode will replace the role's permissions with the patterns below.
+
+ )}
+ ({
+ label: `${role} — include: ${cippRoles[role].include.join(", ") || "none"}${
+ cippRoles[role].exclude.length
+ ? `, exclude: ${cippRoles[role].exclude.join(", ")}`
+ : ""
+ }`,
+ value: role,
+ }))}
+ formControl={formControl}
+ fullWidth={true}
+ multiple={false}
+ creatable={false}
+ helperText="Replaces the patterns below with the selected role's include/exclude rules — edit them freely afterwards."
+ />
+ option.category}
+ renderGroup={(params) => (
+
+ {params.group}
+ {params.children}
+
+ )}
+ helperText="Patterns match Category.Object.Level permission names. * matches anything."
+ />
+ option.category}
+ renderGroup={(params) => (
+
+ {params.group}
+ {params.children}
+
+ )}
+ helperText="Exclusions always win over inclusions, exactly like built-in roles."
+ />
+ {[...currentRules.Include, ...currentRules.Exclude]
+ .filter((pattern) => !validateRulePattern(pattern))
+ .map((pattern) => (
+
+ "{pattern}" is not a valid pattern. Use up to three dot-separated segments
+ of letters, numbers and *, e.g. Identity.User.Read or Exchange.*.
+
+ ))}
+
+
+ Live result
+
+
+ {currentRules.Include.map((pattern) => (
+ 0 ? "success" : "warning"
+ }
+ icon={
+ (ruleExpansion.includeCounts[pattern] ?? 0) === 0 ? (
+
+ ) : undefined
+ }
+ />
+ ))}
+ {currentRules.Exclude.map((pattern) => (
+ 0 ? "error" : "warning"
+ }
+ icon={
+ (ruleExpansion.excludeCounts[pattern] ?? 0) === 0 ? (
+
+ ) : undefined
+ }
+ />
+ ))}
+
+ {currentRules.Include.length === 0 ? (
+
+ Add at least one include pattern — a role with no inclusions grants no
+ access and cannot be saved.
+
+ ) : (
+
+
+ {ruleExpansion.matched.length} of{" "}
+ {permissionUniverse.length} permissions granted
+
+ setRulePreviewVisible(true)}>
+ Preview effective permissions
+
+
+ )}
+ {currentRules.Include.length > 0 && !coreCovered && (
+
+ CIPP.Core.Read is required to sign in and will be added automatically when
+ you save.
+
+ )}
+ setRulePreviewVisible(false)}
+ title="Effective Permissions"
+ size="lg"
+ >
+
+
+ Permissions granted by the current patterns — expand one to see the API
+ endpoints it serves. Struck-through entries were matched by an include
+ pattern but removed by an exclusion.
+
+ {ruleExpansion.matched.map((permission) => {
+ const [permCat, permObj, permType] = permission.split(".");
+ // A ReadWrite grant also serves the Read endpoints (enforcement
+ // matches loosely), so show them unless Read is granted separately.
+ const sections = [
+ { type: permType, endpoints: apiPermissions?.[permCat]?.[permObj]?.[permType] },
+ ];
+ if (
+ permType === "ReadWrite" &&
+ apiPermissions?.[permCat]?.[permObj]?.Read &&
+ !ruleExpansion.matched.includes(`${permCat}.${permObj}.Read`)
+ ) {
+ sections.push({
+ type: "Read (included by ReadWrite)",
+ endpoints: apiPermissions[permCat][permObj].Read,
+ });
+ }
+ const endpointCount = sections.reduce(
+ (total, section) => total + Object.keys(section.endpoints || {}).length,
+ 0
+ );
+ return (
+
+ }
+ sx={{ "& .MuiAccordionSummary-content": { minWidth: 0 } }}
+ >
+
+
+ {permission}
+
+
+
+
+
+
+ {sections.map((section) => (
+
+ {sections.length > 1 && (
+ {section.type}
+ )}
+ {Object.keys(section.endpoints || {}).map((apiKey) => {
+ const apiFunction = section.endpoints[apiKey];
+ const description = getFunctionDescriptionText(
+ apiFunction.Description
+ );
+ return (
+
+
+ {apiFunction.Name}
+
+ {description && (
+
+ {description}
+
+ )}
+
+ );
+ })}
+
+ ))}
+
+
+
+ );
+ })}
+ {Object.entries(ruleExpansion.excludedBy).map(([permission, pattern]) => (
+
+ {permission} (excluded by {pattern})
+
+ ))}
+
+
)}
-
+ {(isBaseRole || permissionMode === "advanced") && (
<>
- {Object.keys(apiPermissions)
- .sort()
- .map((cat, catIndex) => (
-
- }>{cat}
-
- {Object.keys(apiPermissions[cat])
- .sort()
- .map((obj, index) => {
- const readOnly = baseRolePermissions?.[cat] ? true : false;
- return (
-
-
-
- );
- })}
-
-
- ))}
+ {!isBaseRole && (
+
+ Set All Permissions
+
+
+
+
+
+ )}
+
+ <>
+ {Object.keys(apiPermissions)
+ .sort()
+ .map((cat, catIndex) => (
+
+ }>
+ {cat}
+
+
+ {Object.keys(apiPermissions[cat])
+ .sort()
+ .map((obj, index) => {
+ const readOnly = baseRolePermissions?.[cat] ? true : false;
+ return (
+
+
+
+ );
+ })}
+
+
+ ))}
+ >
+
>
-
+ )}
>
)}
-
+
-
+
{selectedEntraGroup && (
This role will be assigned to the Entra Group:{" "}
@@ -898,7 +1274,27 @@ export const CippRoleAddEdit = ({ selectedRole }) => {
>
)}
- {selectedPermissions && apiPermissionSuccess && (
+ {!isBaseRole && permissionMode === "simple" && currentRules.Include.length > 0 && (
+ <>
+ Permission Rules
+
+ {currentRules.Include.map((pattern) => (
+
+ + {pattern}
+
+ ))}
+ {currentRules.Exclude.map((pattern) => (
+
+ − {pattern}
+
+ ))}
+
+
+ {ruleExpansion.matched.length} permissions granted
+
+ >
+ )}
+ {(isBaseRole || permissionMode === "advanced") && selectedPermissions && apiPermissionSuccess && (
<>
Selected Permissions
@@ -917,8 +1313,8 @@ export const CippRoleAddEdit = ({ selectedRole }) => {
>
)}
-
-
+
+
@@ -931,7 +1327,13 @@ export const CippRoleAddEdit = ({ selectedRole }) => {
customRoleListFetching ||
apiPermissionFetching ||
tenantsFetching ||
- !formState.isValid
+ !formState.isValid ||
+ (!isBaseRole &&
+ permissionMode === "simple" &&
+ (currentRules.Include.length === 0 ||
+ [...currentRules.Include, ...currentRules.Exclude].some(
+ (pattern) => !validateRulePattern(pattern)
+ )))
}
startIcon={
diff --git a/src/components/CippSettings/CippRoles.jsx b/src/components/CippSettings/CippRoles.jsx
index 66f637f74791..835af4f1048e 100644
--- a/src/components/CippSettings/CippRoles.jsx
+++ b/src/components/CippSettings/CippRoles.jsx
@@ -1,7 +1,10 @@
import React from "react";
-import { Box, Button, SvgIcon } from "@mui/material";
+import { Alert, Box, Button, Chip, SvgIcon, Typography } from "@mui/material";
+import { useQueryClient } from "@tanstack/react-query";
import { CippDataTable } from "../CippTable/CippDataTable";
-import { PencilIcon, TrashIcon, DocumentDuplicateIcon } from "@heroicons/react/24/outline";
+import { PencilIcon, TrashIcon, DocumentDuplicateIcon, EyeIcon } from "@heroicons/react/24/outline";
+import { usePermissions } from "../../hooks/use-permissions";
+import { enterImpersonation } from "../../utils/impersonation";
import NextLink from "next/link";
import { CippPropertyListCard } from "../../components/CippCards/CippPropertyListCard";
import { getCippTranslation } from "../../utils/get-cipp-translation";
@@ -10,7 +13,48 @@ import { Stack } from "@mui/system";
import { CippCopyToClipBoard } from "../CippComponents/CippCopyToClipboard";
const CippRoles = () => {
+ const queryClient = useQueryClient();
+ const { userRoles } = usePermissions();
+ // While impersonating, /me reports the impersonated roles, so this action disappears
+ // automatically — no nested impersonation; the only way back is the banner's Exit.
+ const isSuperAdmin = userRoles?.includes("superadmin");
+
const actions = [
+ ...(isSuperAdmin
+ ? [
+ {
+ label: "Impersonate Role",
+ icon: (
+
+
+
+ ),
+ confirmText: (
+
+
+ Impersonate this role? CIPP will reload and behave as if you only hold this
+ role — including its tenant restrictions — until you click Exit in the banner
+ at the top of the page. IP restrictions are not simulated.
+
+
+ This tests a single role in isolation , not role combinations.
+ For users holding several roles, custom roles are restrictive, not
+ additive : combined with a base role like editor or readonly they can
+ only narrow access, so a real user's effective permissions may differ from
+ what you see here.
+
+
+ ),
+ // Row-menu passes (row, action, formData); the offcanvas property card passes
+ // (item, data, {}) — resolve the row defensively.
+ customFunction: (a, b) => {
+ const row = a?.RoleName ? a : b;
+ if (row?.RoleName) enterImpersonation(row.RoleName, queryClient);
+ },
+ condition: (row) => row?.RoleName?.toLowerCase() !== "superadmin",
+ },
+ ]
+ : []),
{
label: "Edit",
icon: (
@@ -81,9 +125,27 @@ const CippRoles = () => {
}
});
+ const rules = data["PermissionRules"];
+ const hasRules = Array.isArray(rules?.Include) && rules.Include.length > 0;
+ if (hasRules) {
+ properties.push({
+ label: "Permission Rules",
+ value: (
+
+ {rules.Include.map((pattern, idx) => (
+
+ ))}
+ {(rules.Exclude || []).map((pattern, idx) => (
+
+ ))}
+
+ ),
+ });
+ }
+
if (data["Permissions"] && Object.keys(data["Permissions"]).length > 0) {
properties.push({
- label: "Permissions",
+ label: hasRules ? "Effective Permissions (at last save)" : "Permissions",
value: (
{Object.keys(data["Permissions"])
diff --git a/src/components/CippSettings/CippSSOSettings.jsx b/src/components/CippSettings/CippSSOSettings.jsx
index 9a2f47829706..a4ae5835429d 100644
--- a/src/components/CippSettings/CippSSOSettings.jsx
+++ b/src/components/CippSettings/CippSSOSettings.jsx
@@ -15,6 +15,7 @@ import {
Table,
TableBody,
TableCell,
+ TableContainer,
TableHead,
TableRow,
Typography,
@@ -67,32 +68,41 @@ const samPermissionsUsed = [
},
];
-const PermissionTable = ({ rows, typeLabel }) => (
-
-
-
- Permission
- Why it is needed
-
-
-
- {rows.map((row) => (
-
-
-
- {row.name}
-
-
- {typeLabel}
-
-
-
- {row.reason}
-
+// Exported for the phone-width overflow story: readable consent text is this table's job.
+export const PermissionTable = ({ rows, typeLabel }) => (
+ // TableContainer: the surrounding Card sets overflow: hidden, which cut this table off
+ // with no scroll path — an admin could not read the permission they were asked to approve.
+ // The monospace names also break, so a phone rarely needs the scrollbar at all.
+
+
+
+
+ Permission
+ Why it is needed
- ))}
-
-
+
+
+ {rows.map((row) => (
+
+
+
+ {row.name}
+
+
+ {typeLabel}
+
+
+
+ {row.reason}
+
+
+ ))}
+
+
+
);
const statusLabels = {
@@ -402,23 +412,23 @@ export const CippSSOSettings = () => {
-
+
Status
-
+
{hasAppId && (
<>
-
+
Admin Consent
-
+
{
{data?.appId && (
<>
-
+
App ID
-
+
{data.appId}
@@ -456,12 +466,12 @@ export const CippSSOSettings = () => {
{signInHosts.length > 0 && (
<>
-
+
Sign-in URLs
-
+
{signInHosts.map((host) => (
{
{data?.createdAt && (
<>
-
+
Created
-
+
{new Date(data.createdAt).toLocaleString()}
diff --git a/src/components/CippSettings/CippUserManagement.jsx b/src/components/CippSettings/CippUserManagement.jsx
index b1d84c2f0b70..5c11abf70345 100644
--- a/src/components/CippSettings/CippUserManagement.jsx
+++ b/src/components/CippSettings/CippUserManagement.jsx
@@ -217,7 +217,7 @@ export const CippUserManagement = () => {
{
+ if (!value) return null;
+ const date = new Date(value);
+ if (isNaN(date.getTime())) return value;
+ return `${date.toISOString().slice(0, 16).replace("T", " ")} UTC`;
+};
const CippVersionProperties = () => {
+ const [copied, setCopied] = useState(false);
+
const version = ApiGetCall({
url: "/version.json",
queryKey: "LocalVersion",
@@ -34,24 +43,78 @@ const CippVersionProperties = () => {
);
};
+
+ const hosting = cippVersion?.data?.Hosting;
+ const lastUpdate = cippVersion?.data?.LastUpdate;
+ const lastUpdateText = lastUpdate
+ ? `v${lastUpdate.PreviousVersion} → v${lastUpdate.NewVersion} (${formatUtc(
+ lastUpdate.RecordedAt
+ )})`
+ : "No update recorded yet";
+
+ const handleCopy = async () => {
+ const versionLine = (label, local, remote, outOfDate) =>
+ `${label}: v${local ?? "Unknown"}${outOfDate === true ? ` (v${remote} available)` : ""}`;
+ const text = [
+ versionLine(
+ "Frontend",
+ version?.data?.version,
+ cippVersion?.data?.RemoteCIPPVersion,
+ cippVersion?.data?.OutOfDateCIPP
+ ),
+ versionLine(
+ "Backend",
+ cippVersion?.data?.LocalCIPPAPIVersion,
+ cippVersion?.data?.RemoteCIPPAPIVersion,
+ cippVersion?.data?.OutOfDateCIPPAPI
+ ),
+ `Hosting: ${hosting?.HostingType ?? "Unknown"}`,
+ `SKU: ${hosting?.SKU ?? "Unknown"}`,
+ `Runtime: ${hosting?.RuntimeStack ?? "Unknown"}`,
+ `Last update: ${
+ lastUpdate
+ ? `v${lastUpdate.PreviousVersion} → v${lastUpdate.NewVersion} (${formatUtc(
+ lastUpdate.RecordedAt
+ )})`
+ : "none recorded"
+ }`,
+ ].join("\n");
+ try {
+ await navigator.clipboard.writeText(text);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch (err) {
+ console.error("Failed to copy version info: ", err);
+ }
+ };
+
return (
{
- version.refetch();
- cippVersion.refetch();
- }}
- >
-
-
-
- Check For Updates
-
+
+
+
+
+
+ {copied ? "Copied!" : "Copy for Ticket"}
+
+ {
+ version.refetch();
+ cippVersion.refetch();
+ }}
+ >
+
+
+
+ Check For Updates
+
+
}
title="Version"
isFetching={cippVersion.isFetching}
@@ -73,7 +136,23 @@ const CippVersionProperties = () => {
cippVersion?.data?.OutOfDateCIPPAPI
),
},
- ]}
+ {
+ label: "Hosting",
+ value: hosting?.HostingType ?? "Unknown",
+ },
+ {
+ label: "App Service SKU",
+ value: hosting?.SKU ?? "Unknown",
+ },
+ {
+ label: "Runtime Stack",
+ value: hosting?.RuntimeStack ?? "Unknown",
+ },
+ {
+ label: "Last Updated",
+ value: lastUpdateText,
+ },
+ ].map((item) => ({ ...item, sx: { py: 0.5, px: { xs: 2, md: 3 } } }))}
/>
);
};
diff --git a/src/components/CippStandards/CippStandardAccordion.jsx b/src/components/CippStandards/CippStandardAccordion.jsx
index f4414a72ba2e..782d383e6920 100644
--- a/src/components/CippStandards/CippStandardAccordion.jsx
+++ b/src/components/CippStandards/CippStandardAccordion.jsx
@@ -1154,7 +1154,7 @@ const CippStandardAccordion = ({
) : (
/* Standard mode layout - original grid layout */
-
+
{hasAddedComponents && (
-
+
{/* Add catalog button for Intune Template standard - appears first */}
{standardName.startsWith("standards.IntuneTemplate") && (
diff --git a/src/components/CippStandards/CippStandardDialog.jsx b/src/components/CippStandards/CippStandardDialog.jsx
index 4761ffcab94f..19690ef6f3f7 100644
--- a/src/components/CippStandards/CippStandardDialog.jsx
+++ b/src/components/CippStandards/CippStandardDialog.jsx
@@ -1320,7 +1320,7 @@ const CippStandardDialog = ({
{/* Active Filter Chips */}
{activeFiltersCount > 0 && (
-
+
{selectedCategories.map((category) => (
({
- display: 'flex',
- alignItems: 'center',
- width: '100%',
- maxWidth: '300px',
- minWidth: '200px',
- height: '40px',
- backgroundColor: theme.palette.mode === 'dark' ? '#2A2D3A' : '#F8F9FA',
- border: `1px solid ${theme.palette.mode === 'dark' ? '#404040' : '#E0E0E0'}`,
- borderRadius: '8px',
- padding: '0 12px',
- '&:hover': {
- borderColor: theme.palette.primary.main,
- },
- '&:focus-within': {
- borderColor: theme.palette.primary.main,
- boxShadow: `0 0 0 2px ${alpha(theme.palette.primary.main, 0.2)}`,
- },
- [theme.breakpoints.down('md')]: {
- minWidth: '0',
- maxWidth: 'none',
- flex: 1,
- },
-}))
-
-const ModernSearchInput = styled(InputBase)(({ theme }) => ({
- marginLeft: theme.spacing(1),
- flex: 1,
- fontSize: '14px',
- '& .MuiInputBase-input': {
- padding: '8px 0',
- '&::placeholder': {
- color: theme.palette.text.secondary,
- opacity: 0.7,
- },
- },
-}))
-
-const ModernButton = styled(Button)(({ theme }) => ({
- height: '40px',
- borderRadius: '8px',
- textTransform: 'none',
- fontWeight: 500,
- fontSize: '14px',
- padding: '8px 16px',
- backgroundColor: theme.palette.mode === 'dark' ? '#2A2D3A' : '#F8F9FA',
- border: `1px solid ${theme.palette.mode === 'dark' ? '#404040' : '#E0E0E0'}`,
- color: theme.palette.text.primary,
- minWidth: 'auto',
- whiteSpace: 'nowrap',
- '&:hover': {
- backgroundColor: theme.palette.mode === 'dark' ? '#363A4A' : '#F0F0F0',
- borderColor: theme.palette.primary.main,
- },
- '& .MuiButton-startIcon': {
- marginRight: '8px',
- },
- '& .MuiButton-endIcon': {
- marginLeft: '8px',
- },
- [theme.breakpoints.down('md')]: {
- padding: '8px 12px',
- fontSize: '13px',
- '& .MuiButton-startIcon': {
- marginRight: '6px',
- },
- '& .MuiButton-endIcon': {
- marginLeft: '6px',
- },
- },
- [theme.breakpoints.down('sm')]: {
- padding: '8px 10px',
- fontSize: '12px',
- '& .MuiButton-startIcon': {
- marginRight: '4px',
- },
- '& .MuiButton-endIcon': {
- marginLeft: '4px',
- },
- },
-}))
-
-const RefreshButton = styled(IconButton)(({ theme }) => ({}))
+import {
+ ModernSearchContainer,
+ ModernSearchInput,
+ ModernButton,
+ RefreshButton,
+} from './toolbar-primitives'
export const CIPPTableToptoolbar = React.memo(
({
@@ -164,14 +90,42 @@ export const CIPPTableToptoolbar = React.memo(
setConfiguredSimpleColumns,
queueMetadata,
isInDialog = false,
+ embedded = false,
showBulkExportAction = true,
+ // Mobile card mode: same state, same handlers, different presentation (sheets
+ // instead of menus). Select-mode state lives in CippDataTable so the card list
+ // and this toolbar stay in sync.
+ viewMode = 'table',
+ selectMode = false,
+ onSelectModeChange,
+ selectModeLocked = false,
+ onViewToggle,
+ tableViewActive = false,
+ showReturnToCards = false,
+ // when set, the selection count + Bulk Actions button portal into this node
+ // (the Card header's slot) rather than rendering inline in the toolbar
+ bulkActionsSlot = null,
+ // Live/Cached data-source controls, rendered in the mobile Table options sheet
+ dataSourceControls,
+ // Owned by CippDataTable: this toolbar mounts as two alternating instances (the cards
+ // branch and the renderTopToolbar branch), so state that must survive the cards<->table
+ // flip is passed down as props rather than kept in local useState/useRef here.
+ activeFilters = { graph: null, table: null },
+ setActiveFilters,
+ searchValue = '',
+ setSearchValue,
+ restoredFiltersRef,
+ persistenceKey,
+ parentRow,
}) => {
const popover = usePopover()
const [filtersAnchor, setFiltersAnchor] = useState(null)
const [columnsAnchor, setColumnsAnchor] = useState(null)
const [exportAnchor, setExportAnchor] = useState(null)
const [actionMenuAnchor, setActionMenuAnchor] = useState(null)
- const [searchValue, setSearchValue] = useState('')
+ const [mobileFilterSheetOpen, setMobileFilterSheetOpen] = useState(false)
+ // table branch's own handoff instance — the cards branch (CippMobileTableControls) owns a separate one
+ const mobileFilterSheet = useSheetHandoff(() => setMobileFilterSheetOpen(false))
const mdDown = useMediaQuery((theme) => theme.breakpoints.down('md'))
const settings = useSettings()
@@ -192,22 +146,21 @@ export const CIPPTableToptoolbar = React.memo(
const [originalSimpleColumns, setOriginalSimpleColumns] =
useState(simpleColumns)
const [filterCanvasVisible, setFilterCanvasVisible] = useState(false)
- const [activeFilters, setActiveFilters] = useState({
- graph: null,
- table: null,
- })
const presetKey = (filter) => filter?.id ?? filter?.filterName
- const pageName = router.pathname.split('/').slice(1).join('/')
- const currentTenant = settings?.currentTenant
+ const pageName = persistenceKey ?? (isInDialog ? '' : router.pathname.split('/').slice(1).join('/'))
const [useCompactMode, setUseCompactMode] = useState(false)
const toolbarRef = useRef(null)
const leftContainerRef = useRef(null)
const actionsContainerRef = useRef(null)
+ const wrapActionRow = (original) => attachParentRow(original, parentRow)
+
const getBulkActions = (actions, selectedRows) => {
return (
actions
- ?.filter((action) => !action.link && !action?.hideBulk)
+ // customComponent actions are single-row dialogs; the bulk path renders CippApiDialog
+ // unconditionally, so admitting one here produces an empty dialog with no API behind it.
+ ?.filter((action) => !action.link && !action?.hideBulk && !action?.customComponent)
?.map((action) => ({
...action,
// bulkFilterEligible actions run against the eligible subset of the selection:
@@ -215,8 +168,8 @@ export const CIPPTableToptoolbar = React.memo(
// The default stays all-or-nothing (every selected row must qualify).
disabled: action.condition
? action.bulkFilterEligible
- ? !selectedRows.some((row) => action.condition(row.original))
- : !selectedRows.every((row) => action.condition(row.original))
+ ? !selectedRows.some((row) => action.condition(wrapActionRow(row.original)))
+ : !selectedRows.every((row) => action.condition(wrapActionRow(row.original)))
: false,
})) || []
)
@@ -256,42 +209,68 @@ export const CIPPTableToptoolbar = React.memo(
})
}
- // Track if we've restored filters for this page to prevent infinite loops
- const restoredFiltersRef = useRef(new Set())
+ // Shared refresh dispatch — desktop refresh button and the mobile filter sheet.
+ const handleRefresh = () => {
+ if (typeof refreshFunction === 'object') {
+ refreshFunction.refetch()
+ } else if (typeof refreshFunction === 'function') {
+ refreshFunction()
+ } else if (data && !getRequestData.isFetched) {
+ // do nothing because data was sent native.
+ } else if (getRequestData) {
+ getRequestData.refetch()
+ }
+ }
- useEffect(() => {
- //if usedData changes, deselect all rows
- table.toggleAllRowsSelected(false)
- }, [usedData])
+ // Shared bulk-action dispatch — desktop bulk menu and the mobile bulk sheet must not
+ // drift, so both route through here.
+ const handleBulkAction = (action, closeMenu = () => {}) => {
+ if (action.disabled) {
+ return
+ }
- // Sync currentEffectiveQueryKey with queryKey prop changes (e.g., tenant changes)
- useEffect(() => {
- setCurrentEffectiveQueryKey(queryKey || title)
- // Clear active filter name when query key changes (page load, tenant change, etc.)
- setActiveFilters({ graph: null, table: null })
- }, [queryKey, title])
+ const allSelectedRows = table.getSelectedRowModel().rows
+ const eligibleRows =
+ action.bulkFilterEligible && action.condition
+ ? allSelectedRows.filter((row) => action.condition(wrapActionRow(row.original)))
+ : allSelectedRows
+ const selectedData = eligibleRows.map((row) => wrapActionRow(row.original))
+
+ if (typeof action.customBulkHandler === 'function') {
+ action.customBulkHandler({
+ rows: eligibleRows,
+ data: selectedData,
+ closeMenu,
+ clearSelection: () => table.toggleAllRowsSelected(false),
+ })
+ closeMenu()
+ return
+ }
- //if the currentTenant Switches, remove Graph filters
- useEffect(() => {
- if (currentTenant) {
- setGraphFilterData({})
- // Clear active filter name when tenant changes
- setActiveFilters({ graph: null, table: null })
- // Clear restoration tracking so saved filters can be re-applied
- const restorationKey = `${pageName}-graph`
- restoredFiltersRef.current.delete(restorationKey)
+ // Runs before any state change: setting ready:true first mounts CippApiDialog with
+ // api.noConfirm true, and its mount effect auto-submits into the same customFunction
+ // being called here — every selected row's action fired twice.
+ if (action?.noConfirm && action.customFunction) {
+ eligibleRows.forEach((row) => action.customFunction(wrapActionRow(row.original.original ?? row.original), action, {}))
+ // Deliberately no closeMenu() here — that matches the behaviour this branch had
+ // before; the only thing being fixed is the duplicate invocation.
+ return
}
- }, [currentTenant, pageName])
- //useEffect to set the column visibility to the preferred columns if they exist
+ setActionData({
+ data: selectedData,
+ action: action,
+ ready: true,
+ })
+ createDialog.handleOpen()
+ closeMenu()
+ }
+
+ // Sync currentEffectiveQueryKey with queryKey prop changes (e.g., tenant changes) — a
+ // plain re-derivation from the same props this instance was given, harmless on remount
useEffect(() => {
- if (
- settings?.columnDefaults?.[pageName] &&
- Object.keys(settings?.columnDefaults?.[pageName]).length > 0
- ) {
- setColumnVisibility(settings?.columnDefaults?.[pageName])
- }
- }, [settings?.columnDefaults?.[pageName], router, usedColumns])
+ setCurrentEffectiveQueryKey(queryKey || title)
+ }, [queryKey, title])
useEffect(() => {
setOriginalSimpleColumns(simpleColumns)
@@ -302,6 +281,7 @@ export const CIPPTableToptoolbar = React.memo(
const restorationKey = `${pageName}-graph`
if (
+ pageName &&
settings.persistFilters &&
settings.lastUsedFilters &&
settings.lastUsedFilters[pageName] &&
@@ -371,11 +351,6 @@ export const CIPPTableToptoolbar = React.memo(
title,
])
- // Clear restoration tracking when page changes
- useEffect(() => {
- restoredFiltersRef.current.clear()
- }, [pageName])
-
// Detect overflow and switch to compact mode
useEffect(() => {
const checkOverflow = () => {
@@ -416,16 +391,21 @@ export const CIPPTableToptoolbar = React.memo(
usedColumns?.length,
])
- // Restore last used filter on mount if persistFilters is enabled (non-graph filters)
+ // Restore last used filter on mount if persistFilters is enabled (non-graph filters).
+ // Once-per-page like the graph slot above: keying this on isFetching used to re-arm the
+ // 100ms timer on every fetch settle (once per page of an auto-paginated load), clobbering
+ // whatever filter the user had just applied with the persisted one.
useEffect(() => {
- // Wait for table to be initialized and data to be available
+ const restorationKey = `${pageName}-table`
+ // Wait for table to be initialized and columns to exist (column filters need them)
if (
+ pageName &&
settings.persistFilters &&
settings.lastUsedFilters &&
settings.lastUsedFilters[pageName] &&
table &&
usedColumns.length > 0 &&
- !getRequestData?.isFetching
+ !restoredFiltersRef.current.has(restorationKey)
) {
// Use setTimeout to ensure the table is fully rendered
const timeoutId = setTimeout(() => {
@@ -437,13 +417,17 @@ export const CIPPTableToptoolbar = React.memo(
}
if (last.type === 'global') {
+ restoredFiltersRef.current.add(restorationKey)
table.setGlobalFilter(last.value)
+ // Keep the visible search box in sync with the filter it now represents
+ setSearchValue(typeof last.value === 'string' ? last.value : '')
setActiveFilters((prev) => ({
...prev,
table: { id: last.id, name: last.name, type: last.type },
}))
} else if (last.type === 'column') {
- // Only apply if all filter columns exist in the current table
+ // Only apply if all filter columns exist in the current table; if they don't
+ // yet (columns still streaming in), leave unmarked so a later run retries.
const allColumns = table.getAllColumns().map((col) => col.id)
const filterColumns = Array.isArray(last.value)
? last.value.map((f) => f.id)
@@ -452,7 +436,10 @@ export const CIPPTableToptoolbar = React.memo(
allColumns.includes(colId)
)
if (allExist) {
- table.setShowColumnFilters(true)
+ restoredFiltersRef.current.add(restorationKey)
+ if (viewMode !== 'cards') {
+ table.setShowColumnFilters(true)
+ }
table.setColumnFilters(last.value)
setActiveFilters((prev) => ({
...prev,
@@ -471,7 +458,7 @@ export const CIPPTableToptoolbar = React.memo(
pageName,
table,
usedColumns,
- getRequestData?.isFetching,
+ viewMode,
])
const presetList = ApiGetCall({
@@ -530,12 +517,14 @@ export const CIPPTableToptoolbar = React.memo(
}
return updatedVisibility
})
- settings.handleUpdate({
- columnDefaults: {
- ...settings?.columnDefaults,
- [pageName]: {},
- },
- })
+ if (pageName) {
+ settings.handleUpdate({
+ columnDefaults: {
+ ...settings?.columnDefaults,
+ [pageName]: {},
+ },
+ })
+ }
setColumnsAnchor(null)
}
@@ -560,12 +549,14 @@ export const CIPPTableToptoolbar = React.memo(
}
const saveAsPreferedColumns = () => {
- settings.handleUpdate({
- columnDefaults: {
- ...settings?.columnDefaults,
- [pageName]: columnVisibility,
- },
- })
+ if (pageName) {
+ settings.handleUpdate({
+ columnDefaults: {
+ ...settings?.columnDefaults,
+ [pageName]: columnVisibility,
+ },
+ })
+ }
setColumnsAnchor(null)
}
@@ -658,7 +649,7 @@ export const CIPPTableToptoolbar = React.memo(
}
const persistFilterSlots = (updater) => {
- if (!settings.persistFilters || !settings.setLastUsedFilter) {
+ if (!pageName || !settings.persistFilters || !settings.setLastUsedFilter) {
return
}
const current = normalizePersistedFilters(
@@ -672,6 +663,12 @@ export const CIPPTableToptoolbar = React.memo(
if (activeFilters.table?.type === 'column') {
table.resetColumnFilters()
}
+ // The search box IS the global filter's visible form — a pending debounced
+ // keystroke or stale text would silently overwrite this preset otherwise.
+ if (searchDebounceRef.current) {
+ clearTimeout(searchDebounceRef.current)
+ }
+ setSearchValue(typeof filter === 'string' ? filter : '')
table.setGlobalFilter(filter)
setActiveFilters((prev) => ({
...prev,
@@ -694,8 +691,15 @@ export const CIPPTableToptoolbar = React.memo(
if (filterType === 'column') {
if (activeFilters.table?.type === 'global') {
table.resetGlobalFilter()
+ if (searchDebounceRef.current) {
+ clearTimeout(searchDebounceRef.current)
+ }
+ setSearchValue('')
+ }
+ if (viewMode !== 'cards') {
+ // Card view renders no header row for the filter inputs to appear in
+ table.setShowColumnFilters(true)
}
- table.setShowColumnFilters(true)
table.setColumnFilters(filter)
setActiveFilters((prev) => ({
...prev,
@@ -799,6 +803,10 @@ export const CIPPTableToptoolbar = React.memo(
if (layer === 'table') {
if (activeFilters.table?.type === 'global') {
table.resetGlobalFilter()
+ if (searchDebounceRef.current) {
+ clearTimeout(searchDebounceRef.current)
+ }
+ setSearchValue('')
} else {
table.resetColumnFilters()
}
@@ -816,6 +824,23 @@ export const CIPPTableToptoolbar = React.memo(
}
}
+ // Pages that compute `filters` asynchronously (or swap them per tenant) need the preset
+ // list to follow the prop — state-only init froze it at first render. Deep-equal via
+ // JSON: the prop is usually a fresh array literal every render.
+ const filtersJson = JSON.stringify(filters ?? [])
+ useEffect(() => {
+ const propFilters = JSON.parse(filtersJson)
+ setFilterList((prev) => {
+ const fetchedGraphPresets = (prev ?? []).filter(
+ (f) =>
+ f.type === 'graph' &&
+ !propFilters.some((p) => presetKey(p) === presetKey(f))
+ )
+ return [...propFilters, ...fetchedGraphPresets]
+ })
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [filtersJson])
+
useEffect(() => {
if (api?.url === '/api/ListGraphRequest' && presetList.isSuccess) {
var endpoint = api?.data?.Endpoint?.replace(/^\//, '')
@@ -880,8 +905,117 @@ export const CIPPTableToptoolbar = React.memo(
)
+ // count + button share this gate whether rendered inline or portaled into the header
+ const bulkActionsContent = (
+ <>
+ {(table.getIsAllRowsSelected() || table.getIsSomeRowsSelected()) && (
+
+ {table.getSelectedRowModel().rows.length} rows selected
+
+ )}
+
+ {showBulkActionsButton && (
+
+
+
+ }
+ variant="outlined"
+ size="small"
+ sx={{
+ flexShrink: 0,
+ whiteSpace: 'nowrap',
+ minWidth: 'auto',
+ height: '32px',
+ fontSize: { xs: '12px', md: '14px' },
+ mr: 1,
+ }}
+ >
+ Bulk Actions
+
+ )}
+ >
+ )
+
+ // feeds both CippMobileTableControls (cards) and CippTableFilterSheet (table branch)
+ const mobileColumnItems = table
+ .getAllColumns()
+ .filter((column) => !column.id.startsWith('mrt-'))
+ .map((column) => ({
+ id: column.id,
+ visible: Boolean(column.getIsVisible()),
+ }))
+ const handleToggleColumn = (columnId, visible) =>
+ setColumnVisibility({ ...columnVisibility, [columnId]: !visible })
+ const handleExportCsvClick = () =>
+ document.querySelector(`[data-csv-export="${title}"]`)?.click()
+ const handleExportPdfClick = () =>
+ document.querySelector(`[data-pdf-export="${title}"]`)?.click()
+ const handleViewApiResponse = () =>
+ isInDialog ? setJsonDialogOpen(true) : setOffcanvasVisible(true)
+ const handleEditGraphFilters =
+ api?.url === '/api/ListGraphRequest' ? () => setFilterCanvasVisible(true) : undefined
+ const handleResetFilters = () => setTableFilter('', 'reset', '')
+ const mobileIsRefreshing = Boolean(
+ getRequestData?.isFetching || refreshFunction?.isFetching
+ )
+
return (
<>
+ {viewMode === 'cards' ? (
+
+ ) : undefined
+ }
+ dataSourceControls={dataSourceControls}
+ />
+ ) : (
+ <>
- {/* Refresh Button */}
-
-
- {
- if (typeof refreshFunction === 'object') {
- refreshFunction.refetch()
- } else if (typeof refreshFunction === 'function') {
- refreshFunction()
- } else if (data && !getRequestData.isFetched) {
- // do nothing because data was sent native.
- } else if (getRequestData) {
- getRequestData.refetch()
+ {/* phones refresh from the options sheet instead */}
+ {!mdDown && (
+
+
+
-
- {getRequestData?.isFetchNextPageError ? (
-
- ) : (
-
- )}
-
-
-
-
+
+ {getRequestData?.isFetchNextPageError ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ )}
{/* Search Input */}
@@ -1069,8 +1195,8 @@ export const CIPPTableToptoolbar = React.memo(
)}
- {/* Mobile/Compact Action Button */}
- {(mdDown || useCompactMode) && !hasSelection && (
+ {/* Compact Action Button — desktop compact mode only, the phone table uses the filter sheet */}
+ {!mdDown && useCompactMode && !hasSelection && (
setActionMenuAnchor(event.currentTarget)}
sx={{ flexShrink: 0 }}
@@ -1079,7 +1205,50 @@ export const CIPPTableToptoolbar = React.memo(
)}
- {/* Mobile Action Menu */}
+ {/* phones keep the kebab open regardless of selection, the only route to the
+ sheet (refresh, export, rows-per-page) down there, not just filters */}
+ {(mdDown || (useCompactMode && !hasSelection)) && (
+ {
+ if (mdDown) {
+ setMobileFilterSheetOpen(true)
+ return
+ }
+ setFiltersAnchor(event.currentTarget)
+ }}
+ sx={{
+ flexShrink: 0,
+ ...(mdDown && activeSlotCount > 0 && { color: 'primary.main' }),
+ }}
+ >
+ {mdDown ? (
+
+
+
+ ) : (
+
+ )}
+
+ )}
+
+ {/* way back to cards, far right to match the card bar's toggle position */}
+ {tableViewActive && showReturnToCards && (
+
+
+
+ {/* destination icon: tapping here returns to cards */}
+
+
+
+
+ )}
+
+ {/* Compact Action Menu — desktop compact mode only */}
+ {/* Anchor the nested menus to the stable overflow IconButton — anchoring to
+ event.currentTarget here targets a MenuItem inside a menu that closes in
+ the same tick, which positions the next popover unpredictably. */}
{
- setFiltersAnchor(event.currentTarget)
- setActionMenuAnchor(null)
- }}
- >
-
-
-
- Filters
-
- {
- setColumnsAnchor(event.currentTarget)
+ onClick={() => {
+ setColumnsAnchor(actionMenuAnchor)
setActionMenuAnchor(null)
}}
>
@@ -1116,8 +1277,8 @@ export const CIPPTableToptoolbar = React.memo(
{exportEnabled && (
{
- setExportAnchor(event.currentTarget)
+ onClick={() => {
+ setExportAnchor(actionMenuAnchor)
setActionMenuAnchor(null)
}}
>
@@ -1353,46 +1514,8 @@ export const CIPPTableToptoolbar = React.memo(
mt: { xs: 1, md: 0 },
}}
>
- {/* Selected rows indicator */}
- {(table.getIsAllRowsSelected() ||
- table.getIsSomeRowsSelected()) && (
-
- {table.getSelectedRowModel().rows.length} rows selected
-
- )}
-
- {/* Bulk Actions - inline with toolbar */}
- {showBulkActionsButton && (
-
-
-
- }
- variant="outlined"
- size="small"
- sx={{
- flexShrink: 0,
- whiteSpace: 'nowrap',
- minWidth: 'auto',
- height: '32px',
- fontSize: { xs: '12px', md: '14px' },
- mr: 1,
- }}
- >
- Bulk Actions
-
- )}
+ {/* Selected rows indicator + Bulk Actions - inline, unless portaled into the header */}
+ {!bulkActionsSlot && bulkActionsContent}
{/* Queue tracker */}
- {/* Hidden export buttons for triggering */}
-
-
-
-
+
+ table.setPageSize(size)}
+ pageSizeOptions={[25, 50, 100, 250, 500]}
+ dataSourceControls={dataSourceControls}
+ />
+ >
+ )}
+
+ {bulkActionsSlot && createPortal(bulkActionsContent, bulkActionsSlot)}
+
+ {/* Hidden export buttons for triggering — outside the mode branch so the
+ mobile filter sheet's export items can click them too */}
+
+
+
{/* Bulk Actions Menu - now inline with toolbar */}
@@ -1444,46 +1598,7 @@ export const CIPPTableToptoolbar = React.memo(
{
- if (action.disabled) {
- return
- }
-
- const allSelectedRows = table.getSelectedRowModel().rows
- const selectedRows =
- action.bulkFilterEligible && action.condition
- ? allSelectedRows.filter((row) =>
- action.condition(row.original)
- )
- : allSelectedRows
- const selectedData = selectedRows.map((row) => row.original)
-
- if (typeof action.customBulkHandler === 'function') {
- action.customBulkHandler({
- rows: selectedRows,
- data: selectedData,
- closeMenu: popover.handleClose,
- clearSelection: () => table.toggleAllRowsSelected(false),
- })
- popover.handleClose()
- return
- }
-
- setActionData({
- data: selectedData,
- action: action,
- ready: true,
- })
-
- if (action?.noConfirm && action.customFunction) {
- selectedRows.map((row) =>
- action.customFunction(row.original.original, action, {})
- )
- } else {
- createDialog.handleOpen()
- popover.handleClose()
- }
- }}
+ onClick={() => handleBulkAction(action, popover.handleClose)}
>
{action.icon}
@@ -1523,8 +1638,19 @@ export const CIPPTableToptoolbar = React.memo(
fields={actionData.action?.fields}
api={actionData.action}
row={actionData.data}
- relatedQueryKeys={queryKeys}
{...actionData.action}
+ relatedQueryKeys={[
+ ...(queryKeys
+ ? Array.isArray(queryKeys)
+ ? queryKeys
+ : [queryKeys]
+ : []),
+ ...(Array.isArray(actionData.action?.relatedQueryKeys)
+ ? actionData.action.relatedQueryKeys
+ : actionData.action?.relatedQueryKeys
+ ? [actionData.action.relatedQueryKeys]
+ : []),
+ ].filter(Boolean)}
/>
)}
@@ -1536,6 +1662,7 @@ export const CIPPTableToptoolbar = React.memo(
onClose={() => setFilterCanvasVisible(!filterCanvasVisible)}
contentPadding={1}
keepMounted={true}
+ aboveModal={isInDialog}
>
{
@@ -80,6 +95,41 @@ const compareNullable = (aVal, bVal) => {
return aVal > bVal ? 1 : -1
}
+// walk up from the card surface to the page's scrolling ancestor (LayoutContainer,
+// overflowY auto) and align the surface with its top. the table flips in at the same
+// page slot, so the height measurement taken right after reads a deterministic top
+// and pages with content above the table keep the table in view
+const scrollNodeToScrollableAncestorTop = (node) => {
+ if (!node) {
+ return
+ }
+ let ancestor = node.parentElement
+ while (ancestor && ancestor !== document.body) {
+ const overflowY = window.getComputedStyle(ancestor).overflowY
+ if (overflowY === 'auto' || overflowY === 'scroll') {
+ ancestor.scrollTop += node.getBoundingClientRect().top - ancestor.getBoundingClientRect().top
+ return
+ }
+ ancestor = ancestor.parentElement
+ }
+ window.scrollTo(0, window.scrollY + node.getBoundingClientRect().top)
+}
+
+/**
+ * Column order for a user-curated selection: the selected ids first, in selection order,
+ * then everything else in its existing order. Exported for tests.
+ *
+ * MRT only reads initialState.columnOrder once, so when the graph filter swaps in a new
+ * \$select list after mount, the new columns (dot-delimited nested fields included) were
+ * appended last — and the card view's three detail slots are filled in column order, so a
+ * field the user explicitly selected was exactly the one that overflowed into "+N more".
+ */
+export const orderColumnsBySelection = (allIds, selectedIds) => {
+ const selected = selectedIds.filter((id) => allIds.includes(id))
+ const rest = allIds.filter((id) => !selected.includes(id))
+ return [...selected, ...rest]
+}
+
// ── Module-level constants ──────────────────────────────────────────────────
// These never change between renders, so extracting them avoids creating new
// object references on every render cycle.
@@ -88,6 +138,31 @@ const compareNullable = (aVal, bVal) => {
// and loop the static-data sync effect.
const EMPTY_ARRAY = []
+const buildSubTableColumn = (sub) => ({
+ id: sub.id,
+ header: sub.header ?? sub.id,
+ size: sub.size ?? 120,
+ minSize: sub.minSize ?? 100,
+ enableSorting: false,
+ enableColumnFilter: false,
+ enableGlobalFilter: false,
+ accessorFn: (row) => {
+ if (typeof sub.label === 'function') {
+ return sub.label(row)
+ }
+ return sub.label ?? 'View'
+ },
+ Cell: ({ row }) => (
+
+ ),
+})
+
const SORTING_FNS = {
dateTimeNullsLast: (a, b, id) => {
const aRaw = getRowValueByColumnId(a, id)
@@ -388,6 +463,12 @@ export const CippDataTable = (props) => {
defaultSorting = [],
isInDialog = false,
showBulkExportAction = true,
+ viewMode: viewModeProp,
+ mobileCard,
+ dataSourceControls,
+ subTables = EMPTY_ARRAY,
+ persistenceKey,
+ parentRow,
} = props
// Create a map of column IDs to their filterType for quick lookup
@@ -416,10 +497,10 @@ export const CippDataTable = (props) => {
useState(simpleColumns)
const [usedData, setUsedData] = useState(data)
const [usedColumns, setUsedColumns] = useState([])
+ const lastOrderedSelectionRef = useRef(null)
const [offcanvasVisible, setOffcanvasVisible] = useState(false)
const [offCanvasData, setOffCanvasData] = useState({})
const [offCanvasRowIndex, setOffCanvasRowIndex] = useState(0)
- const [filteredRows, setFilteredRows] = useState([])
const [customComponentData, setCustomComponentData] = useState({})
const [customComponentVisible, setCustomComponentVisible] = useState(false)
const [actionData, setActionData] = useState({
@@ -432,7 +513,41 @@ export const CippDataTable = (props) => {
const [columnFilters, setColumnFilters] = useState([])
const waitingBool = api?.url ? true : false
+ // The cards branch and the renderTopToolbar branch are two alternating CIPPTableToptoolbar
+ // instances (only one is ever mounted), so state that must survive the cards<->table flip
+ // lives here and is passed down as props to both.
+ const [activeFilters, setActiveFilters] = useState({ graph: null, table: null })
+ const [searchValue, setSearchValue] = useState('')
+ const restoredFiltersRef = useRef(new Set())
+
const settings = useSettings()
+ const router = useRouter()
+ const routerPageName = router.pathname.split('/').slice(1).join('/')
+ const pageName = persistenceKey ?? (isInDialog ? '' : routerPageName)
+
+ // 'cards' below the md breakpoint (or when forced via settings/prop), 'table' otherwise.
+ // simple tables always resolve to 'table'.
+ const resolvedViewMode = useTableViewMode({ viewMode: viewModeProp, simple })
+ // same pivot as the cards/table auto mode, so the FAB and the card list agree on width
+ const isNarrowViewport = useIsNarrowForTables()
+ // viewMode prop or simple is a hard force, the toggle never overrides it
+ const toggleAllowed = !viewModeProp && !simple
+ // Mobile select mode: checkboxes on cards + the bottom bulk bar. Lives here so the
+ // toolbar (which renders the Select toggle) and the card list stay in sync. Picker
+ // tables (onChange) force it on — selection is their entire purpose.
+ const [mobileSelectMode, setMobileSelectMode] = useState(false)
+ // portal target for the header's bulk-actions slot, set by the CardHeader's ref callback
+ const [headerBulkSlot, setHeaderBulkSlot] = useState(null)
+
+ // transient cards<->table override, session-only, never persisted
+ const [viewOverride, setViewOverride] = useState(null)
+ const effectiveViewMode = toggleAllowed ? (viewOverride ?? resolvedViewMode) : resolvedViewMode
+ const isCardView = effectiveViewMode === 'cards'
+ const tableViewActive = effectiveViewMode === 'table'
+ const CardViewSurface = noCard ? Box : Card
+ const [narrowTableMaxHeight, setNarrowTableMaxHeight] = useState(null)
+ // way back button: table is only up because of the override, phone default is still cards
+ const showReturnToCards = toggleAllowed && tableViewActive && resolvedViewMode === 'cards'
// Hook to trigger re-render when license backfill completes
const { updateTrigger } = useLicenseBackfill()
@@ -578,8 +693,12 @@ export const CippDataTable = (props) => {
})
} else if (configuredSimpleColumns.length > 0) {
// Resolve any variables in the simple columns before checking visibility
- const resolvedSimpleColumns = resolveSimpleColumnVariables(
- configuredSimpleColumns,
+ const resolvedSimpleColumns = resolveSubTableSimpleColumns(
+ resolveSimpleColumnVariables(
+ configuredSimpleColumns,
+ usedData
+ ),
+ subTables,
usedData
)
@@ -595,6 +714,25 @@ export const CippDataTable = (props) => {
newVisibility[col.id] = finalResolvedColumns.includes(col.id)
}
})
+ // Selection order wins over data-key order — but only when the resolved selection
+ // changed (including subTable cachedColumn swaps), so a data refetch doesn't
+ // stomp a manual column reorder.
+ const resolvedOrderKey = finalResolvedColumns.join('|')
+ if (lastOrderedSelectionRef.current !== resolvedOrderKey) {
+ lastOrderedSelectionRef.current = resolvedOrderKey
+ const subTableIds = getSubTableDisplayColumnIds(
+ subTables,
+ configuredSimpleColumns,
+ usedData
+ )
+ const allIds = [
+ ...new Set([
+ ...finalColumns.map((col) => col.id).filter(Boolean),
+ ...subTableIds,
+ ]),
+ ]
+ table.setColumnOrder(orderColumnsBySelection(allIds, finalResolvedColumns))
+ }
} else {
const providedColumnKeys = new Set(
columns.map((col) => col.id || col.header)
@@ -630,8 +768,68 @@ export const CippDataTable = (props) => {
queryKey,
settings?.currentTenant,
filterTypeMap,
+ subTables,
])
+ // Previous-value refs for the guards below: CippDataTable is the single owner of this
+ // state across both toolbar instances, so an effect can compare against the last value
+ // it actually saw rather than firing unconditionally on every render.
+ const prevTenantRef = useRef(settings?.currentTenant)
+ const prevQueryKeyRef = useRef(queryKey || title)
+ const prevPageNameRef = useRef(pageName)
+ const appliedColumnDefaultsRef = useRef({})
+
+ // if the currentTenant switches, remove graph filters and the active-filter highlight
+ useEffect(() => {
+ const currentTenant = settings?.currentTenant
+ if (prevTenantRef.current === currentTenant) {
+ return
+ }
+ prevTenantRef.current = currentTenant
+ if (currentTenant) {
+ setGraphFilterData({})
+ setActiveFilters({ graph: null, table: null })
+ restoredFiltersRef.current.delete(`${pageName}-graph`)
+ }
+ }, [settings?.currentTenant, pageName])
+
+ // clear the active-filter highlight when the effective query key changes (tenant swap,
+ // different queryKey/title)
+ useEffect(() => {
+ const effectiveKey = queryKey || title
+ if (prevQueryKeyRef.current === effectiveKey) {
+ return
+ }
+ prevQueryKeyRef.current = effectiveKey
+ setActiveFilters({ graph: null, table: null })
+ }, [queryKey, title])
+
+ // clear persisted-filter restoration tracking only when the page actually changes
+ useEffect(() => {
+ if (prevPageNameRef.current === pageName) {
+ return
+ }
+ prevPageNameRef.current = pageName
+ restoredFiltersRef.current.clear()
+ }, [pageName])
+
+ // apply preferred columns once per page, and again whenever the saved preference's
+ // identity changes. Nested dialog tables must not read or write the parent page key.
+ useEffect(() => {
+ if (!pageName) {
+ return
+ }
+ const preferred = settings?.columnDefaults?.[pageName]
+ if (
+ preferred &&
+ Object.keys(preferred).length > 0 &&
+ appliedColumnDefaultsRef.current[pageName] !== preferred
+ ) {
+ appliedColumnDefaultsRef.current[pageName] = preferred
+ setColumnVisibility(preferred)
+ }
+ }, [settings?.columnDefaults?.[pageName], pageName])
+
const createDialog = useDialog()
const hasActions = !!actions
const hasOffCanvas = !!offCanvas
@@ -648,7 +846,9 @@ export const CippDataTable = (props) => {
offCanvas,
onChange,
maxHeightOffset,
- settings
+ settings,
+ effectiveViewMode,
+ isNarrowViewport
),
[
simple,
@@ -657,6 +857,8 @@ export const CippDataTable = (props) => {
hasOnChange,
maxHeightOffset,
settings?.tablePageSize?.value,
+ effectiveViewMode,
+ isNarrowViewport,
]
)
@@ -678,12 +880,70 @@ export const CippDataTable = (props) => {
return result
}, [columnVisibility])
+ const displayColumns = useMemo(() => {
+ if (!Array.isArray(subTables) || subTables.length === 0) {
+ return usedColumns
+ }
+ const cachedHeaders = new Map()
+ const injected = []
+ for (const sub of subTables) {
+ if (!subTableIsSelected(sub, configuredSimpleColumns)) {
+ continue
+ }
+ if (subTableShowsCachedColumn(sub, usedData)) {
+ cachedHeaders.set(sub.cachedColumn, sub.header ?? sub.id)
+ continue
+ }
+ injected.push(buildSubTableColumn(sub))
+ }
+ const columns = cachedHeaders.size
+ ? usedColumns.map((col) =>
+ cachedHeaders.has(col.id)
+ ? { ...col, header: cachedHeaders.get(col.id) }
+ : col
+ )
+ : usedColumns
+ const injectedById = new Map(injected.map((col) => [col.id, col]))
+ const replaced = columns.map((col) => injectedById.get(col.id) ?? col)
+ const existing = new Set(columns.map((col) => col.id))
+ return [...replaced, ...injected.filter((col) => !existing.has(col.id))]
+ }, [usedColumns, usedData, subTables, configuredSimpleColumns])
+
+ useEffect(() => {
+ if (!Array.isArray(subTables) || subTables.length === 0) {
+ return
+ }
+ setColumnVisibility((prev) => {
+ const next = { ...prev }
+ let changed = false
+ for (const sub of subTables) {
+ if (!subTableIsSelected(sub, configuredSimpleColumns)) {
+ continue
+ }
+ const columnId = subTableShowsCachedColumn(sub, usedData)
+ ? sub.cachedColumn
+ : sub.id
+ if (next[columnId] === undefined) {
+ next[columnId] = true
+ changed = true
+ }
+ }
+ return changed ? next : prev
+ })
+ }, [subTables, configuredSimpleColumns, usedData])
+
const handleActionDisabled = useCallback((row, action) => {
+ const actionRow = attachParentRow(row, parentRow)
if (action?.condition) {
- return !action.condition(row)
+ return !action.condition(actionRow)
}
return false
- }, [])
+ }, [parentRow])
+
+ const getActionRow = useCallback(
+ (rowOriginal) => attachParentRow(rowOriginal, parentRow),
+ [parentRow]
+ )
// Stable callback for sorting changes.
const handleSortingChange = useCallback((newSorting) => {
@@ -733,12 +993,12 @@ export const CippDataTable = (props) => {
}
setOffCanvasData(row.original)
- const filteredRowsArray = table?.getFilteredRowModel?.()?.rows
- if (filteredRowsArray) {
- const indexInFiltered = filteredRowsArray.findIndex(
+ const navigable = table?.getSortedRowModel?.()?.rows
+ if (navigable) {
+ const indexInList = navigable.findIndex(
(r) => r.original === row.original
)
- setOffCanvasRowIndex(indexInFiltered >= 0 ? indexInFiltered : 0)
+ setOffCanvasRowIndex(indexInList >= 0 ? indexInList : 0)
}
setOffcanvasVisible(true)
},
@@ -758,7 +1018,7 @@ export const CippDataTable = (props) => {
const renderEmptyRowsFallback = useCallback(
({ table }) =>
queueMessage ? (
-
+
{queueMessage}
@@ -785,6 +1045,72 @@ export const CippDataTable = (props) => {
[sanitizedColumnVisibility, sorting, columnFilters, showSkeletons]
)
+ // Single row-action dispatch used by BOTH the desktop row menu and the mobile action
+ // sheet — the two presentations must not drift.
+ // `table` is referenced via closure: it is declared below but initialized before any
+ // handler can run (the same pattern the row menu has always relied on).
+ const dispatchRowAction = useCallback(
+ (action, rowOriginal, closeMenu = () => {}) => {
+ const scopeToRowTenant = () => {
+ const tenant = getRowTenant(getActionRow(rowOriginal), settings.currentTenant)
+ if (settings.currentTenant === 'AllTenants' && tenant && tenant !== 'AllTenants') {
+ settings.handleUpdate({
+ currentTenant: tenant,
+ })
+ }
+ }
+
+ if (action.noConfirm && action.customFunction) {
+ scopeToRowTenant()
+ action.customFunction(getActionRow(rowOriginal), action, {})
+ closeMenu()
+ return
+ }
+
+ // Handle custom component differently
+ if (typeof action.customComponent === 'function') {
+ scopeToRowTenant()
+ setCustomComponentData({ data: getActionRow(rowOriginal), action: action })
+ setCustomComponentVisible(true)
+ closeMenu()
+ return
+ }
+
+ // Standard dialog flow
+ setActionData({
+ data: getActionRow(rowOriginal),
+ action: action,
+ ready: true,
+ })
+ createDialog.handleOpen()
+ closeMenu()
+ },
+ [settings, createDialog, getActionRow]
+ )
+
+ // Open the extended-info offcanvas for a row, recording its position in the row model so
+ // prev/next navigation works. Shared by the row menu, the mobile action sheet, and card
+ // taps. The SORTED model is the one on screen — the filtered model is pre-sort, so a
+ // position taken from it stops matching the list the moment a column is sorted.
+ const openRowOffCanvas = useCallback((rowOriginal) => {
+ setOffCanvasData(rowOriginal)
+ const navigable = table.getSortedRowModel().rows
+ const indexInList = navigable.findIndex((r) => r.original === rowOriginal)
+ setOffCanvasRowIndex(indexInList >= 0 ? indexInList : 0)
+ setOffcanvasVisible(true)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ // the flipped table shows whatever columns are visible; horizontal scroll covers the width
+ const cardViewSurfaceRef = useRef(null)
+ const handleViewToggle = useCallback(() => {
+ const nextView = effectiveViewMode === 'table' ? 'cards' : 'table'
+ setViewOverride(nextView)
+ if (nextView === 'table' && isNarrowViewport) {
+ scrollNodeToScrollableAncestorTop(cardViewSurfaceRef.current)
+ }
+ }, [effectiveViewMode, isNarrowViewport])
+
// Memoize renderRowActionMenuItems to avoid re-creating on each render.
const renderRowActionMenuItems = useMemo(() => {
if (actions) {
@@ -795,49 +1121,13 @@ export const CippDataTable = (props) => {
// condition, which renders it disabled).
(action) =>
typeof action.hideCondition !== 'function' ||
- !action.hideCondition(row.original)
+ !action.hideCondition(getActionRow(row.original))
)
.map((action, index) => (
{
- const scopeToRowTenant = () => {
- if (
- settings.currentTenant === 'AllTenants' &&
- row.original?.Tenant
- ) {
- settings.handleUpdate({
- currentTenant: row.original.Tenant,
- })
- }
- }
-
- if (action.noConfirm && action.customFunction) {
- scopeToRowTenant()
- action.customFunction(row.original, action, {})
- closeMenu()
- return
- }
-
- // Handle custom component differently
- if (typeof action.customComponent === 'function') {
- scopeToRowTenant()
- setCustomComponentData({ data: row.original, action: action })
- setCustomComponentVisible(true)
- closeMenu()
- return
- }
-
- // Standard dialog flow
- setActionData({
- data: row.original,
- action: action,
- ready: true,
- })
- createDialog.handleOpen()
- closeMenu()
- }}
+ onClick={() => dispatchRowAction(action, row.original, closeMenu)}
disabled={handleActionDisabled(row.original, action)}
>
@@ -851,14 +1141,7 @@ export const CippDataTable = (props) => {
key={`actions-list-row-more`}
onClick={() => {
closeMenu()
- setOffCanvasData(row.original)
- // Find the index of this row in the filtered rows
- const filteredRowsArray = table.getFilteredRowModel().rows
- const indexInFiltered = filteredRowsArray.findIndex(
- (r) => r.original === row.original
- )
- setOffCanvasRowIndex(indexInFiltered >= 0 ? indexInFiltered : 0)
- setOffcanvasVisible(true)
+ openRowOffCanvas(row.original)
}}
>
@@ -875,13 +1158,7 @@ export const CippDataTable = (props) => {
{
closeMenu()
- setOffCanvasData(row.original)
- const filteredRowsArray = table.getFilteredRowModel().rows
- const indexInFiltered = filteredRowsArray.findIndex(
- (r) => r.original === row.original
- )
- setOffCanvasRowIndex(indexInFiltered >= 0 ? indexInFiltered : 0)
- setOffcanvasVisible(true)
+ openRowOffCanvas(row.original)
}}
>
@@ -896,9 +1173,10 @@ export const CippDataTable = (props) => {
}, [
actions,
offCanvas,
- settings.currentTenant,
+ dispatchRowAction,
+ openRowOffCanvas,
handleActionDisabled,
- createDialog,
+ getActionRow,
])
// Stable renderTopToolbar — memoized so MaterialReactTable doesn't re-create the toolbar
@@ -916,8 +1194,8 @@ export const CippDataTable = (props) => {
data={data}
columnVisibility={columnVisibility}
getRequestData={getRequestData}
- usedColumns={usedColumns}
- usedData={memoizedData ?? []}
+ usedColumns={displayColumns}
+ usedData={memoizedData ?? EMPTY_ARRAY}
title={title}
actions={actions}
exportEnabled={exportEnabled}
@@ -929,8 +1207,20 @@ export const CippDataTable = (props) => {
setGraphFilterData={setGraphFilterData}
setConfiguredSimpleColumns={setConfiguredSimpleColumns}
queueMetadata={getRequestData.data?.pages?.[0]?.Metadata}
+ persistenceKey={persistenceKey}
+ parentRow={parentRow}
isInDialog={isInDialog}
showBulkExportAction={showBulkExportAction}
+ onViewToggle={toggleAllowed ? handleViewToggle : undefined}
+ tableViewActive={toggleAllowed ? tableViewActive : undefined}
+ showReturnToCards={showReturnToCards}
+ bulkActionsSlot={isNarrowViewport && !isInDialog ? headerBulkSlot : null}
+ dataSourceControls={dataSourceControls}
+ activeFilters={activeFilters}
+ setActiveFilters={setActiveFilters}
+ searchValue={searchValue}
+ setSearchValue={setSearchValue}
+ restoredFiltersRef={restoredFiltersRef}
/>
)}
>
@@ -945,6 +1235,7 @@ export const CippDataTable = (props) => {
columnVisibility,
getRequestData,
usedColumns,
+ displayColumns,
memoizedData,
title,
actions,
@@ -954,6 +1245,15 @@ export const CippDataTable = (props) => {
graphFilterData,
isInDialog,
showBulkExportAction,
+ toggleAllowed,
+ handleViewToggle,
+ tableViewActive,
+ showReturnToCards,
+ isNarrowViewport,
+ headerBulkSlot,
+ dataSourceControls,
+ activeFilters,
+ searchValue,
]
)
@@ -976,14 +1276,26 @@ export const CippDataTable = (props) => {
columnFilters: columnFilters,
columnVisibility: sanitizedColumnVisibility,
},
- columns: usedColumns,
- data: memoizedData ?? [],
+ columns: displayColumns,
+ data: memoizedData ?? EMPTY_ARRAY,
state: tableState,
onSortingChange: handleSortingChange,
onColumnFiltersChange: setColumnFilters,
renderEmptyRowsFallback,
onColumnVisibilityChange: setColumnVisibility,
...modeInfo,
+ // narrow table views size their scroll viewport from measurement (see the effect below),
+ // the modeInfo calc budget only holds for desktop chrome
+ ...(isNarrowViewport &&
+ effectiveViewMode === 'table' && {
+ muiTableContainerProps: {
+ ...modeInfo.muiTableContainerProps,
+ sx: {
+ ...modeInfo.muiTableContainerProps?.sx,
+ maxHeight: narrowTableMaxHeight ? `${narrowTableMaxHeight}px` : 'none',
+ },
+ },
+ }),
renderRowActionMenuItems,
renderTopToolbar,
sortingFns: SORTING_FNS,
@@ -994,29 +1306,173 @@ export const CippDataTable = (props) => {
renderColumnFilterModeMenuItems: renderColumnFilterModeMenuItemsFn,
})
+ // deselect all rows when the underlying data set actually changes, guarded so a toolbar
+ // remount (cards<->table flip) does not wipe an in-progress selection
+ const prevUsedDataRef = useRef(memoizedData)
+ useEffect(() => {
+ if (prevUsedDataRef.current === memoizedData) {
+ return
+ }
+ prevUsedDataRef.current = memoizedData
+ table.toggleAllRowsSelected(false)
+ }, [memoizedData])
+
+ // utilTableMode seeds columnOrder from simpleColumns (e.g. "members"), but cached report
+ // data shows membersCsv instead — MRT crashes if order references ids that are not in
+ // displayColumns.
+ useEffect(() => {
+ if (!Array.isArray(subTables) || subTables.length === 0) {
+ return
+ }
+ const displayIds = displayColumns.map((col) => col.id).filter(Boolean)
+ if (displayIds.length === 0) {
+ return
+ }
+ const currentOrder = table.getState().columnOrder ?? []
+ if (!columnOrderHasStaleIds(currentOrder, displayIds)) {
+ return
+ }
+ const selectedForOrder = resolveSubTableSimpleColumns(
+ configuredSimpleColumns,
+ subTables,
+ usedData
+ ).filter((id) => displayIds.includes(id))
+ table.setColumnOrder(orderColumnsBySelection(displayIds, selectedForOrder))
+ }, [configuredSimpleColumns, displayColumns, subTables, usedData, table])
+
+ // size the narrow table's scroll viewport from where it actually sits: viewport height
+ // minus the container's measured top, the real footer height and the chrome below the
+ // paper. the desktop calc assumes chrome heights that phone layouts do not have.
+ // deps include getRequestData.isSuccess so a cold load (table not yet mounted when the
+ // toggle flips tableViewActive true) re-arms the measurement once MRT actually renders
+ useEffect(() => {
+ if (!(isNarrowViewport && tableViewActive)) {
+ setNarrowTableMaxHeight(null)
+ return undefined
+ }
+ const measure = () => {
+ const container = table.refs.tableContainerRef?.current
+ if (!container) {
+ return
+ }
+ const footer = table.refs.bottomToolbarRef?.current?.offsetHeight ?? 0
+ // chrome between the paper's bottom edge and the page bottom (CardContent padding + page gap)
+ const BELOW_PAPER_PX = 40
+ // viewport-relative, the toggle handler aligns the card surface with the scroll
+ // viewport top first so this reads a deterministic position
+ const top = container.getBoundingClientRect().top
+ let next = Math.max(240, Math.floor(window.innerHeight - top - footer - BELOW_PAPER_PX))
+ // 120 = minimal chrome allowance, keeps the table from claiming the full viewport
+ next = Math.min(next, window.innerHeight - 120)
+ setNarrowTableMaxHeight((prev) => {
+ if (prev !== null && Math.abs(prev - next) <= 1) {
+ return prev
+ }
+ return next
+ })
+ }
+ const raf = requestAnimationFrame(() => requestAnimationFrame(measure))
+ window.addEventListener('resize', measure)
+ let observer
+ // the paper mounts in the same commit as the container and the footer, so it is a
+ // reliable observation target even on the pass where the footer ref is still null
+ const paper = table.refs.tablePaperRef?.current
+ if (typeof ResizeObserver !== 'undefined' && paper) {
+ observer = new ResizeObserver(measure)
+ observer.observe(paper)
+ }
+ return () => {
+ cancelAnimationFrame(raf)
+ window.removeEventListener('resize', measure)
+ observer?.disconnect()
+ }
+ }, [isNarrowViewport, tableViewActive, table, getRequestData.isSuccess])
+
+ // A card shows at most a title, subtitle, three chips and three detail rows, so the rest
+ // of the row has to live in the drawer. On a page with no offCanvas that means every
+ // column the user has chosen to show; on a page that configured one, its curated fields
+ // come first and the remaining visible columns are appended rather than dropped — on
+ // desktop those columns are still on screen in the table, on mobile they are not.
+ const cardInfoFields = useMemo(() => {
+ if (!isCardView) return undefined
+ // A page that renders its own drawer body (offCanvas.children — the test-detail pages)
+ // is the authority on what the drawer shows; prepending the generic property list on
+ // top of it repeated Risk/Status above a body that already presents them.
+ if (offCanvas?.children) return undefined
+ const visible = table
+ .getVisibleLeafColumns()
+ .map((column) => column.id)
+ .filter((id) => !id.startsWith('mrt-'))
+ const curated = offCanvas?.extendedInfoFields
+ if (!curated?.length) return visible
+ // Curated order wins; dedupe case-insensitively across both lists, and within the
+ // curated list itself, so a field can't be shown twice.
+ const seen = new Set()
+ return [...curated, ...visible].filter((id) => {
+ if (typeof id !== 'string') return false
+ const key = id.toLowerCase()
+ if (seen.has(key)) return false
+ seen.add(key)
+ return true
+ })
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [offCanvas, isCardView, table, columnVisibility, usedColumns])
+
+ // Applied after the {...offCanvas} spread below, which carries the page's own
+ // extendedInfoFields and would otherwise overwrite the merged list. Card view renders
+ // them the way the table cells do, since the appended entries are columns.
+ const cardInfoOverride =
+ isCardView && cardInfoFields?.length
+ ? { extendedInfoFields: cardInfoFields, richFormatting: true }
+ : {}
+
+ // The rows the drawer's Prev/Next walks, in the order they are on screen. Read live from
+ // the table on every render: this used to be mirrored into state by an effect keyed on
+ // filters and sorting, so the copy was taken once at mount — before the rows had arrived
+ // — and every table that loads its data asynchronously reported nothing to navigate.
+ const navigationRows = table.getSortedRowModel().rows
+ // Prefer the position of the row actually on show, so sorting or filtering while the
+ // drawer is open carries the counter with it. The stored index covers the case where the
+ // row has left the list entirely — a background refetch replaces every object, so
+ // identity alone would strand the position — and is clamped so a list that shrank under
+ // it can't report "6 of 2".
+ const derivedRowIndex = offcanvasVisible
+ ? navigationRows.findIndex((row) => row.original === offCanvasData)
+ : -1
+ const currentRowIndex =
+ derivedRowIndex >= 0
+ ? derivedRowIndex
+ : Math.min(offCanvasRowIndex, Math.max(navigationRows.length - 1, 0))
+
// Remove the useEffect that was resetting filters on table changes
// The initial filter application is now handled by the columnFilters state
// and the useEffect above that only triggers on actual filter prop changes
+ // Exiting mobile select mode clears the selection — "Done" means done.
+ const handleMobileSelectModeChange = useCallback(
+ (on) => {
+ setMobileSelectMode(on)
+ if (!on) {
+ table.toggleAllRowsSelected(false)
+ }
+ },
+ [table]
+ )
+
+ // Empty-state "Clear filters" in the card list. The full reset (graph filters,
+ // persisted slots) lives in the toolbar's filter sheet; this only clears what makes
+ // the current list empty.
+ const handleClearAllFilters = useCallback(() => {
+ table.resetGlobalFilter()
+ table.resetColumnFilters()
+ }, [table])
+
useEffect(() => {
if (onChange && table.getSelectedRowModel().rows) {
onChange(table.getSelectedRowModel().rows.map((row) => row.original))
}
}, [table.getSelectedRowModel().rows])
- useEffect(() => {
- // Update filtered rows whenever table filtering/sorting changes
- if (table && table.getFilteredRowModel) {
- const rows = table.getFilteredRowModel().rows
- setFilteredRows(rows.map((row) => row.original))
- }
- }, [
- table,
- table.getState().columnFilters,
- table.getState().globalFilter,
- table.getState().sorting,
- ])
-
useEffect(() => {
//check if the simplecolumns are an array,
if (Array.isArray(simpleColumns) && simpleColumns.length > 0) {
@@ -1024,9 +1480,126 @@ export const CippDataTable = (props) => {
}
}, [simpleColumns])
+ const selectModeActive = hasOnChange ? true : mobileSelectMode
+
+ const resolvedCardButton = cardButton ? (
+
+ ) : undefined
+
+ // below md, table-in-Card branch: the actions FAB carries cardButton
+ const headerAction = isNarrowViewport && !isInDialog ? undefined : resolvedCardButton
+
return (
<>
- {noCard ? (
+ {isCardView ? (
+ // same paper surface as the table path; overflow visible keeps the controls bar sticky
+
+ {!hideTitle && (
+
+
+ {title}
+
+ {Array.isArray(usedData) && !showSkeletons && (
+
+ {table.getFilteredRowModel().rows.length} results
+
+ )}
+
+ )}
+ {!Array.isArray(usedData) && usedData ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+ {getRequestData.isError && !getRequestData.isFetchNextPageError && (
+ getRequestData.refetch()}
+ message={`Error Loading data: ${getCippError(getRequestData.error)}`}
+ />
+ )}
+
+ ) : noCard ? (
{!Array.isArray(usedData) && usedData ? (
@@ -1046,40 +1619,61 @@ export const CippDataTable = (props) => {
) : (
// Render the table inside a Card
-
- {cardButton || !hideTitle ? (
- <>
-
-
- >
- ) : null}
-
-
- {!Array.isArray(usedData) && usedData ? (
-
- ) : (
- <>
- {(getRequestData.isSuccess ||
- getRequestData.data?.pages.length >= 0 ||
- (data && !getRequestData.isError)) && (
-
- )}
- >
- )}
- {getRequestData.isError &&
- !getRequestData.isFetchNextPageError && (
- getRequestData.refetch()}
- message={`Error Loading data: ${getCippError(getRequestData.error)}`}
- />
+ <>
+
+ {cardButton || !hideTitle ? (
+ <>
+
+
+ {/* narrow viewports carry these in the Table options sheet */}
+ {dataSourceControls && !isNarrowViewport ? (
+
+ {dataSourceControls}
+ {headerAction}
+
+ ) : (
+ headerAction
+ )}
+
+ }
+ title={hideTitle ? '' : title}
+ {...props.cardHeaderProps}
+ />
+
+ >
+ ) : null}
+
+
+ {!Array.isArray(usedData) && usedData ? (
+
+ ) : (
+ <>
+ {(getRequestData.isSuccess ||
+ getRequestData.data?.pages.length >= 0 ||
+ (data && !getRequestData.isError)) && (
+
+ )}
+ >
)}
-
-
-
+ {getRequestData.isError &&
+ !getRequestData.isFetchNextPageError && (
+ getRequestData.refetch()}
+ message={`Error Loading data: ${getCippError(getRequestData.error)}`}
+ />
+ )}
+
+
+
+ {isNarrowViewport && !isInDialog && resolvedCardButton && (
+ {resolvedCardButton}
+ )}
+ >
)}
{
onClose={() => setOffcanvasVisible(false)}
extendedData={offCanvasData}
extendedInfoFields={offCanvas?.extendedInfoFields}
- actions={actions}
title={offCanvasData?.Name || offCanvas?.title || 'Extended Info'}
+ aboveModal={isInDialog}
children={
offCanvas?.children
- ? (row) => offCanvas.children(row, offCanvasRowIndex)
+ ? (row) => offCanvas.children(row, currentRowIndex)
: undefined
}
customComponent={offCanvas?.customComponent}
onNavigateUp={() => {
- const newIndex = offCanvasRowIndex - 1
- if (newIndex >= 0 && filteredRows && filteredRows[newIndex]) {
+ const newIndex = currentRowIndex - 1
+ if (newIndex >= 0 && navigationRows[newIndex]) {
setOffCanvasRowIndex(newIndex)
- setOffCanvasData(filteredRows[newIndex])
+ setOffCanvasData(navigationRows[newIndex].original)
}
}}
onNavigateDown={() => {
- const newIndex = offCanvasRowIndex + 1
- if (filteredRows && newIndex < filteredRows.length) {
+ const newIndex = currentRowIndex + 1
+ if (navigationRows[newIndex]) {
setOffCanvasRowIndex(newIndex)
- setOffCanvasData(filteredRows[newIndex])
+ setOffCanvasData(navigationRows[newIndex].original)
}
}}
- canNavigateUp={offCanvasRowIndex > 0}
- canNavigateDown={
- filteredRows && offCanvasRowIndex < filteredRows.length - 1
- }
+ canNavigateUp={currentRowIndex > 0}
+ canNavigateDown={currentRowIndex < navigationRows.length - 1}
+ navigationPosition={{
+ index: currentRowIndex + 1,
+ total: navigationRows.length,
+ }}
{...offCanvas}
+ {...cardInfoOverride}
+ // Retired: the drawer's action buttons never dispatched reliably, and the row menu
+ // is the actions surface. Last so it beats page configs carrying offCanvas.actions.
+ actions={undefined}
/>
{/* Render custom component */}
{customComponentVisible &&
@@ -1140,8 +1740,15 @@ export const CippDataTable = (props) => {
fields={actionData.action?.fields}
api={actionData.action}
row={actionData.data}
- relatedQueryKeys={queryKey ? queryKey : title}
{...actionData.action}
+ relatedQueryKeys={[
+ ...(queryKey ? [queryKey] : title ? [title] : []),
+ ...(Array.isArray(actionData.action?.relatedQueryKeys)
+ ? actionData.action.relatedQueryKeys
+ : actionData.action?.relatedQueryKeys
+ ? [actionData.action.relatedQueryKeys]
+ : []),
+ ].filter(Boolean)}
/>
)
}, [
diff --git a/src/components/CippTable/CippDataTableButton.jsx b/src/components/CippTable/CippDataTableButton.jsx
index 86c3c887e1e9..aa8702a17448 100644
--- a/src/components/CippTable/CippDataTableButton.jsx
+++ b/src/components/CippTable/CippDataTableButton.jsx
@@ -1,11 +1,45 @@
-import { useState } from "react";
-import { Dialog, DialogContent, Button } from "@mui/material";
+import { useMemo, useState } from "react";
+import { Dialog, DialogContent, DialogTitle, IconButton, Button, useMediaQuery } from "@mui/material";
+import CloseIcon from "@mui/icons-material/Close";
import { CippDataTable } from "./CippDataTable";
import { getCippTranslation } from "../../utils/get-cipp-translation";
-const CippDataTableButton = ({ data, title, tableTitle = "Data" }) => {
+import { resolveRowTemplates, getRowTenant } from "../../utils/resolve-row-templates";
+import { useSettings } from "../../hooks/use-settings";
+
+const applyTenantFilterDefault = (api, row, currentTenant) => {
+ if (!api) {
+ return api;
+ }
+ const data = { ...(api.data || {}) };
+ if (data.tenantFilter === undefined && data.TenantFilter === undefined) {
+ const tenant = getRowTenant(row, currentTenant);
+ if (tenant) {
+ data.tenantFilter = tenant;
+ }
+ }
+ return { ...api, data };
+};
+
+const CippDataTableButton = ({
+ data,
+ title,
+ tableTitle = "Data",
+ row,
+ api,
+ label,
+ condition,
+ queryKey,
+ ...tableProps
+}) => {
const [openDialogs, setOpenDialogs] = useState([]);
+ const [liveOpen, setLiveOpen] = useState(false);
+ const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md"));
+ const settings = useSettings();
+ const isLive = Boolean(api?.url);
+
+ const nestedTitle = title ?? tableTitle ?? "Data";
- const handleOpenDialog = (event) => {
+ const handleOpenStaticDialog = (event) => {
event?.stopPropagation();
let dataArray;
@@ -23,10 +57,49 @@ const CippDataTableButton = ({ data, title, tableTitle = "Data" }) => {
setOpenDialogs([...openDialogs, dataArray]);
};
- const handleCloseDialog = (index, event) => {
+ const handleCloseStaticDialog = (index, event) => {
event?.stopPropagation?.();
setOpenDialogs(openDialogs.filter((_, i) => i !== index));
};
+
+ const handleOpenLiveDialog = (event) => {
+ event?.stopPropagation();
+ setLiveOpen(true);
+ };
+
+ const handleCloseLiveDialog = (event) => {
+ event?.stopPropagation?.();
+ setLiveOpen(false);
+ };
+
+ const liveTableProps = useMemo(() => {
+ if (!isLive || !liveOpen) {
+ return null;
+ }
+ const templatedApi = applyTenantFilterDefault(
+ resolveRowTemplates(api, row),
+ row,
+ settings?.currentTenant
+ );
+ const templatedQueryKey = queryKey
+ ? resolveRowTemplates(queryKey, row)
+ : undefined;
+ const templatedTitle = resolveRowTemplates(nestedTitle, row);
+ const { title: _ignoredTitle, ...rest } = tableProps;
+
+ return {
+ ...rest,
+ api: templatedApi,
+ queryKey: templatedQueryKey,
+ title: templatedTitle,
+ parentRow: row,
+ isInDialog: true,
+ simple: rest.simple ?? false,
+ hideTitle: mdDown,
+ maxHeightOffset: rest.maxHeightOffset ?? "160px",
+ };
+ }, [api, isLive, liveOpen, mdDown, nestedTitle, queryKey, row, settings?.currentTenant, tableProps]);
+
const dataIsNotANullArray =
!Array.isArray(data) &&
(typeof data !== "object" || data === null || Object.keys(data).length === 0);
@@ -36,38 +109,99 @@ const CippDataTableButton = ({ data, title, tableTitle = "Data" }) => {
? Object.keys(data).length
: 0;
+ const liveDisabled = typeof condition === "function" ? !condition(row) : false;
+ const buttonLabel = isLive
+ ? typeof label === "function"
+ ? label(row)
+ : label ?? "View"
+ : dataIsNotANullArray
+ ? "No items"
+ : `${dataLength} items`;
+
+ const dialogTitle = isLive
+ ? liveTableProps?.title ?? nestedTitle
+ : tableTitle;
+
return (
<>
- {dataIsNotANullArray ? "No items" : `${dataLength} items`}
+ {buttonLabel}
- {openDialogs.map((dialogData, index) => (
+ {isLive && liveOpen && liveTableProps && (
handleCloseDialog(index, event)}
+ onClose={handleCloseLiveDialog}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
fullWidth
+ fullScreen={mdDown}
maxWidth="lg"
>
-
-
+ {mdDown && (
+
+
+
+
+ {dialogTitle}
+
+ )}
+
+
- ))}
+ )}
+
+ {!isLive &&
+ openDialogs.map((dialogData, index) => (
+ handleCloseStaticDialog(index, event)}
+ onMouseDown={(event) => event.stopPropagation()}
+ onClick={(event) => event.stopPropagation()}
+ fullWidth
+ fullScreen={mdDown}
+ maxWidth="lg"
+ >
+ {mdDown && (
+
+ handleCloseStaticDialog(index, event)}
+ aria-label="Close"
+ sx={{ minWidth: 44, minHeight: 44 }}
+ >
+
+
+ {tableTitle}
+
+ )}
+
+
+
+
+ ))}
>
);
};
diff --git a/src/components/CippTable/CippDiagnosticsFilter.js b/src/components/CippTable/CippDiagnosticsFilter.js
index e8118a10e090..3eeb44cf002f 100644
--- a/src/components/CippTable/CippDiagnosticsFilter.js
+++ b/src/components/CippTable/CippDiagnosticsFilter.js
@@ -19,8 +19,11 @@ import { CippFormComponent } from "../CippComponents/CippFormComponent";
import { ApiGetCall, ApiPostCall } from "../../api/ApiCall";
import { Grid } from "@mui/system";
import defaultPresets from "../../data/DiagnosticsPresets.json";
+import { useIsMobileLayout } from "../../hooks/use-breakpoint";
const CippDiagnosticsFilter = ({ onSubmitFilter }) => {
+ // A 12-row monospace query box is roughly half a phone viewport before anything else.
+ const isMobile = useIsMobileLayout();
const [expanded, setExpanded] = useState(true);
const [selectedPreset, setSelectedPreset] = useState(null);
const [presetOptions, setPresetOptions] = useState([]);
@@ -270,7 +273,7 @@ const CippDiagnosticsFilter = ({ onSubmitFilter }) => {
label="KQL Query"
formControl={formControl}
multiline
- rows={12}
+ rows={isMobile ? 6 : 12}
placeholder={`Enter your KQL query here, for example:\n\ntraces\n| where timestamp > ago(1h)\n| where severityLevel >= 2\n| project timestamp, message, severityLevel\n| order by timestamp desc`}
helperText="Enter a valid Kusto Query Language (KQL) query to execute against Application Insights"
sx={{
@@ -281,7 +284,7 @@ const CippDiagnosticsFilter = ({ onSubmitFilter }) => {
}}
/>
-
+
{
+ if (endpointFilter && formControl.getValues('endpoint') !== endpointFilter) {
formControl.setValue('endpoint', endpointFilter)
}
- presetFilter = { Endpoint: endpointFilter }
- }
+ }, [endpointFilter, formControl])
+
+ const presetFilter = endpointFilter ? { Endpoint: endpointFilter } : {}
// API call for available presets
const presetList = ApiGetCall({
@@ -723,7 +728,7 @@ const CippGraphExplorerFilter = ({
{/* Reverse Tenant Lookup Switch */}
-
+
{/* Reverse Tenant Lookup Property Field */}
-
+
{/* No Pagination Switch */}
-
+
{/* $count Switch */}
-
+
{/* AsApp switch */}
-
+
{component === 'accordion' ? (
-
+
) : (
-
+
-
+
}
variant="outlined"
@@ -870,7 +879,7 @@ const CippGraphExplorerFilter = ({
Schedule Report
-
+
-
+
}
variant="outlined"
@@ -893,7 +902,7 @@ const CippGraphExplorerFilter = ({
-
+
-
+
-
-
+ {/* This sits on a page with no Card, so at 390px there is ~358px for a query field
+ plus three buttons whose fixed minimums alone came to 340px. */}
+
+
-
+
}
onClick={handleRunPreset}
disabled={!selectedPreset && !currentFilterValues}
- sx={{ minWidth: "100px" }}
+ sx={{ minWidth: { md: "100px" } }}
>
Run
@@ -171,7 +180,7 @@ const CippGraphExplorerSimpleFilter = ({
variant="outlined"
startIcon={ }
onClick={() => setOffCanvasVisible(true)}
- sx={{ minWidth: "120px" }}
+ sx={{ minWidth: { md: "120px" } }}
>
Edit Query
@@ -180,7 +189,7 @@ const CippGraphExplorerSimpleFilter = ({
variant="outlined"
startIcon={viewMode === "table" ? : }
onClick={() => onViewModeChange(viewMode === "table" ? "json" : "table")}
- sx={{ minWidth: "120px" }}
+ sx={{ minWidth: { md: "120px" } }}
>
{viewMode === "table" ? "View JSON" : "View Table"}
diff --git a/src/components/CippTable/CippMobileCardList.jsx b/src/components/CippTable/CippMobileCardList.jsx
new file mode 100644
index 000000000000..fa208ae5eb2f
--- /dev/null
+++ b/src/components/CippTable/CippMobileCardList.jsx
@@ -0,0 +1,444 @@
+import { useEffect, useMemo, useState } from "react";
+import {
+ Box,
+ Button,
+ Card,
+ Checkbox,
+ IconButton,
+ LinearProgress,
+ ListItemButton,
+ ListItemIcon,
+ ListItemText,
+ Skeleton,
+ Stack,
+ SvgIcon,
+ Typography,
+} from "@mui/material";
+import { flexRender } from "material-react-table";
+import { Info, MoreVert, MoreHoriz, SearchOff } from "@mui/icons-material";
+import { getCippTranslation } from "../../utils/get-cipp-translation";
+import { renderUrlValue } from "../../utils/render-url-value";
+import { getMobileCardSlots } from "./util-mobile-card-slots";
+import { CippBottomSheet } from "../CippComponents/CippBottomSheet";
+import { CippPageActionsFab } from "../CippComponents/CippPageActionsFab";
+import { useActionCornerClaim } from "../../layouts/tab-navigation-context";
+import { useSheetHandoff } from "../../hooks/use-sheet-handoff";
+
+// Mobile card pageSize ceiling: a desktop tablePageSize of 250/500 must not become
+// 250 unvirtualized cards. "Load more" grows pageSize from here in steps of LOAD_STEP.
+const MOBILE_PAGE_SIZE_CAP = 50;
+const LOAD_STEP = 50;
+
+// Chip values that say nothing without their field name beside them.
+const MUTE_ALONE = new Set(["high", "medium", "low", "critical", "informational"]);
+
+// Render one column's value for a row. Generated columns (util-columnsFromAPI) only use
+// { row } in their Cell, but page-supplied columns may expect fuller MRT context, so we
+// hand over the real cell context when the cell exists.
+const renderCellValue = (row, column, table) => {
+ const columnDef = column?.columnDef ?? column;
+ try {
+ const cell = row.getAllCells().find((c) => c.column.id === column.id);
+ // A portal cell is a bare icon — legible under its column header, not on a card row
+ // that only carries a label. Spell the link out from the raw value instead.
+ const linked = renderUrlValue(row.original?.[column.id], column.id);
+ if (linked) return linked;
+ if (typeof columnDef?.Cell === "function") {
+ return flexRender(columnDef.Cell, {
+ row,
+ cell,
+ column: cell?.column ?? column,
+ table,
+ renderedCellValue: cell ? cell.getValue() : row.getValue(column.id),
+ });
+ }
+ return cell ? cell.getValue() : row.getValue(column.id);
+ } catch {
+ return null;
+ }
+};
+
+// String form for the card title/subtitle: the accessorFn output (getCippFormatting text
+// mode for generated columns) — never a React node inside noWrap Typography.
+const textValue = (row, column) => {
+ if (!column) return null;
+ try {
+ const value = row.getValue(column.id);
+ return typeof value === "string" || typeof value === "number" ? String(value) : null;
+ } catch {
+ return null;
+ }
+};
+
+const SkeletonCard = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+export const CippMobileCardList = (props) => {
+ const {
+ table,
+ actions,
+ hasOffCanvas = false,
+ onRowAction,
+ onMoreInfo,
+ isActionDisabled,
+ getActionRow = (row) => row,
+ selectMode = false,
+ cardButton,
+ mobileCard,
+ fixedChrome = true,
+ onClearFilters,
+ isStreaming = false,
+ queueMessage,
+ } = props;
+
+ const [actionSheetRow, setActionSheetRow] = useState(null);
+ // Row actions and More info both open a Modal — hand the sheet off rather than racing it
+ const rowSheet = useSheetHandoff(() => setActionSheetRow(null));
+
+ // Select mode's bulk bar owns the bottom of the screen, so the page FAB steps aside. Hold
+ // the corner through it anyway: a headered layout would otherwise drop its actions FAB in
+ // behind the bulk bar. Navigation is unaffected — the tab picker is in the title row.
+ useActionCornerClaim(fixedChrome && selectMode);
+
+ // A desktop tablePageSize above the cap would render that many unvirtualized cards.
+ useEffect(() => {
+ if (table.getState().pagination.pageSize > MOBILE_PAGE_SIZE_CAP) {
+ table.setPageSize(MOBILE_PAGE_SIZE_CAP);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const rows = table.getRowModel().rows;
+ const totalFiltered = table.getFilteredRowModel().rows.length;
+ const showSkeletons = table.getState().showSkeletons;
+ const { globalFilter, columnFilters } = table.getState();
+ const hasActiveFilter = Boolean(globalFilter) || (columnFilters?.length ?? 0) > 0;
+
+ const visibleColumns = table.getVisibleLeafColumns();
+ const slots = useMemo(
+ () => getMobileCardSlots(visibleColumns, mobileCard),
+ // visibleColumns is a fresh array each call — key on the ids it contains
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [visibleColumns.map((c) => c.id).join(","), mobileCard]
+ );
+
+ const rowActionItems = (row) =>
+ (actions ?? []).filter(
+ (action) =>
+ typeof action.hideCondition !== "function" || !action.hideCondition(getActionRow(row.original))
+ );
+
+ // Detail rows that would waste space: empty values, or values already shown as the
+ // card's title/subtitle (e.g. mail duplicating the UPN on most user rows).
+ const visibleDetailColumns = (row) => {
+ const shown = [textValue(row, slots.primary), slots.secondary && textValue(row, slots.secondary)]
+ .filter(Boolean)
+ .map((v) => v.toLowerCase());
+ return slots.details.filter((col) => {
+ let raw;
+ try {
+ raw = row.getValue(col.id);
+ } catch {
+ return true;
+ }
+ if (raw === null || raw === undefined || raw === "") return false;
+ if (Array.isArray(raw) && raw.length === 0) return false;
+ if (typeof raw === "string" && shown.includes(raw.toLowerCase())) return false;
+ return true;
+ });
+ };
+
+ const handleCardTap = (event, row) => {
+ if (
+ event.target?.closest?.(
+ 'button, a, input, textarea, select, [role="button"], [role="menuitem"], [data-no-row-click="true"]'
+ )
+ ) {
+ return;
+ }
+ if (selectMode) {
+ row.toggleSelected();
+ return;
+ }
+ if (hasOffCanvas) {
+ onMoreInfo?.(row.original);
+ }
+ };
+
+ const handleLoadMore = () => {
+ table.setPageSize(table.getState().pagination.pageSize + LOAD_STEP);
+ };
+
+ const loadedCount = Math.min(rows.length, totalFiltered);
+
+ return (
+
+ {isStreaming && !showSkeletons && }
+ {/* pb clears the fixed FAB / bulk bar — chrome an embedded (noCard/dialog) list does
+ not have, so it pays a normal gap instead of 80px of blank card. */}
+
+ {showSkeletons ? (
+ Array.from({ length: 5 }, (_, i) => )
+ ) : totalFiltered === 0 ? (
+
+
+ {queueMessage ? : }
+
+
+ {queueMessage ?? "No results"}
+
+ {hasActiveFilter && (
+ <>
+
+ Nothing matches the current search and filters.
+
+
+ Clear filters
+
+ >
+ )}
+
+ ) : (
+ <>
+ {rows.map((row) => {
+ const selected = row.getIsSelected();
+ const detailColumns = visibleDetailColumns(row);
+ return (
+ handleCardTap(event, row)}
+ sx={{
+ p: 2,
+ display: "flex",
+ gap: 1.25,
+ position: "relative",
+ cursor: selectMode || hasOffCanvas ? "pointer" : "default",
+ ...(selected && {
+ borderColor: "primary.main",
+ bgcolor: (theme) =>
+ theme.palette.mode === "dark"
+ ? "rgba(247,127,0,.08)"
+ : "primary.alpha8",
+ }),
+ }}
+ >
+ {selectMode && (
+ row.toggleSelected()}
+ sx={{ alignSelf: "flex-start", p: 1, m: -0.5 }}
+ inputProps={{ "aria-label": `Select ${textValue(row, slots.primary) ?? row.id}` }}
+ />
+ )}
+
+
+ {textValue(row, slots.primary) ?? "—"}
+
+ {slots.secondary && (
+
+ {textValue(row, slots.secondary)}
+
+ )}
+ {slots.chips.length > 0 && (
+
+ {slots.chips.map((col) => {
+ // Booleans format as a bare ✓/✕ icon — meaningful under a column
+ // header, meaningless floating on a card. Give those chips their
+ // field name in a labeled pill ("Primary ✓", "Account Enabled ✕").
+ // Severity words are just as mute alone: a "High" chip beside a
+ // "Passed" chip doesn't say what is high, so those keep their field
+ // name too — as a caption, since the chip is its own container.
+ const text = textValue(row, col);
+ const isBareBoolean = text === "Yes" || text === "No";
+ const isMuteAlone = MUTE_ALONE.has(String(text ?? "").toLowerCase());
+ return (
+
+ {(isBareBoolean || isMuteAlone) && (
+
+ {getCippTranslation(col.id)}
+
+ )}
+ {renderCellValue(row, col, table)}
+
+ );
+ })}
+
+ )}
+ {detailColumns.length > 0 && (
+ // Grid so every label shares the width of the longest one — no fixed
+ // label column truncating "Business Phones" while values sit half-empty.
+
+ {detailColumns.map((col) => (
+
+
+ {getCippTranslation(col.id)}
+
+ *": { verticalAlign: "middle" },
+ }}
+ >
+ {renderCellValue(row, col, table)}
+
+
+ ))}
+
+ )}
+ {slots.restCount > 0 && hasOffCanvas && (
+ {
+ event.stopPropagation();
+ onMoreInfo?.(row.original);
+ }}
+ role="button"
+ >
+ +{slots.restCount} more field{slots.restCount === 1 ? "" : "s"}
+
+ )}
+
+ {(actions?.length > 0 || hasOffCanvas) && !selectMode && (
+ {
+ event.stopPropagation();
+ setActionSheetRow(row);
+ }}
+ sx={{ position: "absolute", top: 4, right: 4, minWidth: 44, minHeight: 44 }}
+ >
+
+
+ )}
+
+ );
+ })}
+
+
+ Showing {loadedCount} of {totalFiltered}
+ {isStreaming ? " (loading…)" : ""}
+
+ {loadedCount < totalFiltered && (
+
+ Load {Math.min(LOAD_STEP, totalFiltered - loadedCount)} more
+
+ )}
+
+ >
+ )}
+
+
+ {/* Page-level add actions: the cardButton children, stacked in a sheet behind one FAB */}
+ {cardButton && fixedChrome && !selectMode && (
+ {cardButton}
+ )}
+
+ {/* Row actions sheet — same actions array, same dispatch as the desktop row menu */}
+
+ {actionSheetRow &&
+ rowActionItems(actionSheetRow).map((action, index) => {
+ const disabled = isActionDisabled?.(actionSheetRow.original, action) ?? false;
+ return (
+
+ rowSheet.run(() => onRowAction?.(action, actionSheetRow.original))
+ }
+ sx={{ minHeight: 48, color: action.color }}
+ >
+
+ {action.icon}
+
+
+
+ );
+ })}
+ {actionSheetRow && hasOffCanvas && (
+ rowSheet.run(() => onMoreInfo?.(actionSheetRow.original))}
+ sx={{ minHeight: 48 }}
+ >
+
+
+
+
+
+
+
+ )}
+
+
+ );
+};
diff --git a/src/components/CippTable/CippMobileTableControls.jsx b/src/components/CippTable/CippMobileTableControls.jsx
new file mode 100644
index 000000000000..57e19e319482
--- /dev/null
+++ b/src/components/CippTable/CippMobileTableControls.jsx
@@ -0,0 +1,312 @@
+import { useState } from "react";
+import {
+ Badge,
+ Box,
+ Button,
+ Divider,
+ ListItemButton,
+ ListItemIcon,
+ ListItemText,
+ SvgIcon,
+ Typography,
+} from "@mui/material";
+import {
+ ModernSearchContainer,
+ ModernSearchInput,
+ ModernButton,
+ ModernIconButton,
+} from "./toolbar-primitives";
+import {
+ ArrowDownward,
+ ArrowUpward,
+ MoreVert,
+ RestartAlt,
+ Search,
+ SwapVert,
+ TableChart,
+} from "@mui/icons-material";
+import { getCippTranslation } from "../../utils/get-cipp-translation";
+import { CippBottomSheet } from "../CippComponents/CippBottomSheet";
+import { CippTableFilterSheet } from "./CippTableFilterSheet";
+import { useSheetHandoff } from "../../hooks/use-sheet-handoff";
+
+// Presentational mobile controls for the card list. All filter/sort/visibility state and
+// handlers are owned by CIPPTableToptoolbar (the same instance the desktop toolbar uses),
+// so persistence, presets, and graph filters flow through exactly one code path.
+export const CippMobileTableControls = (props) => {
+ const {
+ table,
+ searchValue,
+ onSearchChange,
+ onRefresh,
+ isRefreshing = false,
+ selectionEnabled = false,
+ selectMode = false,
+ onSelectModeChange,
+ selectModeLocked = false,
+ onViewToggle,
+ customBulkActions = [],
+ onBulkAction,
+ graphPresetItems = [],
+ tablePresetItems = [],
+ activeFilters = { graph: null, table: null },
+ activeSlotCount = 0,
+ presetKey,
+ onPresetClick,
+ onResetFilters,
+ onEditGraphFilters,
+ columnItems = [],
+ onToggleColumn,
+ exportEnabled = false,
+ onExportCsv,
+ onExportPdf,
+ onViewApiResponse,
+ fixedChrome = true,
+ embedded = false,
+ queueTracker,
+ dataSourceControls,
+ } = props;
+
+ const [sortOpen, setSortOpen] = useState(false);
+ const [filterOpen, setFilterOpen] = useState(false);
+ const [bulkOpen, setBulkOpen] = useState(false);
+ // Graph filters, the API-response drawer and bulk dialogs are all Modals; let the sheet
+ // finish closing before they mount (see useSheetHandoff).
+ const filterSheet = useSheetHandoff(() => setFilterOpen(false));
+ const bulkSheet = useSheetHandoff(() => setBulkOpen(false));
+
+ const sorting = table.getState().sorting ?? [];
+ const sortableColumns = table
+ .getAllColumns()
+ .filter((column) => !column.id.startsWith("mrt-") && column.getCanSort());
+
+ // Tap cycles: none -> asc -> desc -> none. Single-column sort — replaces, not appends.
+ const cycleSort = (columnId) => {
+ const current = sorting.find((s) => s.id === columnId);
+ if (!current) {
+ table.setSorting([{ id: columnId, desc: false }]);
+ } else if (!current.desc) {
+ table.setSorting([{ id: columnId, desc: true }]);
+ } else {
+ table.setSorting([]);
+ }
+ };
+
+ const selectedCount = table.getSelectedRowModel().rows.length;
+ const totalCount = table.getFilteredRowModel().rows.length;
+ const enabledBulkActions = customBulkActions.filter((action) => !action.disabled);
+
+ return (
+ <>
+
+
+
+
+
+ {selectionEnabled && !selectModeLocked && (
+ onSelectModeChange?.(!selectMode)}
+ sx={{ height: 44, flexShrink: 0 }}
+ >
+ {selectMode ? "Cancel" : "Select"}
+
+ )}
+ setSortOpen(true)}
+ sx={sorting.length ? { borderColor: "primary.main", color: "primary.main" } : undefined}
+ >
+
+
+ {/* kebab, the sheet is a grab-bag (presets, fields, export, refresh), not just filters */}
+ setFilterOpen(true)}
+ sx={
+ activeSlotCount > 0
+ ? { borderColor: "primary.main", color: "primary.main" }
+ : undefined
+ }
+ >
+
+
+
+
+ {onViewToggle && (
+
+ {/* destination icon: tapping here opens the table */}
+
+
+ )}
+
+ {queueTracker && {queueTracker} }
+
+ {/* Sort sheet — net-new on mobile: cards have no column headers to click */}
+ setSortOpen(false)}
+ title="Sort by"
+ footer={
+ setSortOpen(false)}>
+ Done
+
+ }
+ >
+ {sortableColumns.map((column) => {
+ const current = sorting.find((s) => s.id === column.id);
+ return (
+ cycleSort(column.id)}
+ sx={{ minHeight: 48, color: current ? "primary.main" : "inherit" }}
+ >
+
+ {current && (
+
+ {current.desc ? : }
+
+ )}
+
+ );
+ })}
+ {sorting.length > 0 && (
+ <>
+
+ table.setSorting([])} sx={{ minHeight: 48 }}>
+
+
+
+
+
+ >
+ )}
+
+
+ {/* Filter sheet — presets first, then table utilities, then card fields */}
+
+
+ {/* Bulk action bar — bottom, in thumb reach, instead of the desktop top-toolbar strip */}
+ {selectMode && selectionEnabled && (
+ theme.zIndex.speedDial,
+ display: "flex",
+ alignItems: "center",
+ gap: 1,
+ px: 1.5,
+ pt: 1.25,
+ pb: "calc(env(safe-area-inset-bottom) + 12px)",
+ bgcolor: "background.paper",
+ borderTop: 1,
+ borderColor: "divider",
+ }}
+ >
+
+ {selectedCount} selected
+
+ table.toggleAllRowsSelected(true)}
+ sx={{ mr: "auto", flexShrink: 0 }}
+ >
+ Select all ({totalCount})
+
+ {customBulkActions.length > 0 && (
+ setBulkOpen(true)}
+ sx={{ minHeight: 40 }}
+ >
+ Actions
+
+ )}
+ {!selectModeLocked && (
+ onSelectModeChange?.(false)}
+ sx={{ minHeight: 40, borderColor: "divider" }}
+ >
+ Done
+
+ )}
+
+ )}
+
+ {/* Bulk actions sheet — the same customBulkActions + dispatch as the desktop menu */}
+
+ {customBulkActions.map((action, index) => (
+ bulkSheet.run(() => onBulkAction(action))}
+ sx={{ minHeight: 48 }}
+ >
+
+ {action.icon}
+
+
+
+ ))}
+
+ >
+ );
+};
diff --git a/src/components/CippTable/CippQueueTracker.js b/src/components/CippTable/CippQueueTracker.js
index 20a4fd6d62cd..66521172833b 100644
--- a/src/components/CippTable/CippQueueTracker.js
+++ b/src/components/CippTable/CippQueueTracker.js
@@ -34,9 +34,10 @@ export const CippQueueTracker = ({ queueId, queryKey, title, onQueueComplete })
data: { QueueId: effectiveQueueId },
queryKey: `CippQueue-${effectiveQueueId || "unknown"}`,
waiting: shouldShowQueue && !!effectiveQueueId && !isQueueCompleted,
- refetchInterval: (data) => {
- // Check if the current data shows completion
- const currentData = data?.[0];
+ refetchInterval: (query) => {
+ // TanStack Query v5 hands this callback the Query object, not the data - the response
+ // has to be read off query.state or the completion check below never matches.
+ const currentData = query?.state?.data?.[0];
const isCurrentCompleted =
currentData?.Status === "Completed" ||
currentData?.Status === "Failed" ||
@@ -257,7 +258,7 @@ export const CippQueueTracker = ({ queueId, queryKey, title, onQueueComplete })
/>
-
+
Total Tasks: {(persistentQueueData || queueData).TotalTasks || 0}
@@ -363,13 +364,23 @@ export const CippQueueTracker = ({ queueId, queryKey, title, onQueueComplete })
direction="row"
justifyContent="space-between"
alignItems="center"
+ spacing={1}
>
-
+ {/* Task names are tenant domains — one unbreakable token — so
+ without minWidth: 0 the row's min-content width exceeds a
+ phone-width card and shoves the status pill off its edge. */}
+
{task.Name}
({
+ flexShrink: 0,
+ whiteSpace: "nowrap",
px: 1.5,
py: 0.5,
borderRadius: 2,
diff --git a/src/components/CippTable/CippTableCardButton.jsx b/src/components/CippTable/CippTableCardButton.jsx
new file mode 100644
index 000000000000..d3960a6e5fc9
--- /dev/null
+++ b/src/components/CippTable/CippTableCardButton.jsx
@@ -0,0 +1,73 @@
+import React from 'react'
+import { Button } from '@mui/material'
+import { Stack } from '@mui/system'
+import { CippApiDialog } from '../CippComponents/CippApiDialog'
+import { useDialog } from '../../hooks/use-dialog'
+import { resolveRowTemplates } from '../../utils/resolve-row-templates'
+
+const isActionConfig = (value) =>
+ Boolean(value) &&
+ typeof value === 'object' &&
+ !React.isValidElement(value) &&
+ !Array.isArray(value) &&
+ (typeof value.url === 'string' || typeof value.link === 'string')
+
+const CippTableActionButton = ({ action, row }) => {
+ const createDialog = useDialog()
+
+ if (typeof action.condition === 'function' && !action.condition(row)) {
+ return null
+ }
+
+ return (
+ <>
+
+ {action.label}
+
+
+ >
+ )
+}
+
+export const CippTableCardButton = ({ cardButton, row }) => {
+ if (!cardButton) {
+ return null
+ }
+ if (typeof cardButton === 'function') {
+ return cardButton(row)
+ }
+ if (Array.isArray(cardButton)) {
+ return (
+
+ {cardButton.map((item, index) => (
+
+ ))}
+
+ )
+ }
+ if (isActionConfig(cardButton)) {
+ return
+ }
+ return cardButton
+}
diff --git a/src/components/CippTable/CippTableFilterSheet.jsx b/src/components/CippTable/CippTableFilterSheet.jsx
new file mode 100644
index 000000000000..373fa59f586a
--- /dev/null
+++ b/src/components/CippTable/CippTableFilterSheet.jsx
@@ -0,0 +1,215 @@
+import {
+ Box,
+ Button,
+ Checkbox,
+ Chip,
+ Divider,
+ ListItemButton,
+ ListItemIcon,
+ ListItemText,
+ ListSubheader,
+ Stack,
+} from "@mui/material";
+import {
+ Check,
+ DataObject,
+ FileDownload,
+ FilterList,
+ PictureAsPdf,
+ RestartAlt,
+ Sync,
+} from "@mui/icons-material";
+import { getCippTranslation } from "../../utils/get-cipp-translation";
+import { CippBottomSheet } from "../CippComponents/CippBottomSheet";
+
+// Shared filter bottom sheet — presets, then the table utilities (refresh, export, reset),
+// then field visibility. Used by the mobile card list and the mobile/compact table toolbar,
+// one code path for both.
+export const CippTableFilterSheet = (props) => {
+ const {
+ open,
+ onClose,
+ onExited,
+ run,
+ tablePresetItems = [],
+ graphPresetItems = [],
+ activeFilters = { graph: null, table: null },
+ presetKey,
+ onPresetClick,
+ columnItems = [],
+ onToggleColumn,
+ onResetFilters,
+ onEditGraphFilters,
+ exportEnabled = false,
+ onExportCsv,
+ onExportPdf,
+ onViewApiResponse,
+ onRefresh,
+ isRefreshing = false,
+ // section renders only when onPageSizeChange is provided (the table-view sheet)
+ pageSize,
+ onPageSizeChange,
+ pageSizeOptions = [],
+ dataSourceControls,
+ } = props;
+
+ const renderPresetChips = (items, layer) => (
+
+ {items.map((filter) => {
+ const key = presetKey(filter);
+ const active = activeFilters[layer]?.id === key;
+ return (
+ : undefined}
+ onClick={() => onPresetClick(filter)}
+ sx={{ height: 36, borderRadius: 999 }}
+ />
+ );
+ })}
+
+ );
+
+ return (
+
+ Done
+
+ }
+ >
+ {dataSourceControls && (
+ <>
+
+ Data source
+
+ {dataSourceControls}
+ >
+ )}
+ {tablePresetItems.length > 0 && (
+ <>
+
+ Presets
+
+ {renderPresetChips(tablePresetItems, "table")}
+ >
+ )}
+ {graphPresetItems.length > 0 && (
+ <>
+
+ Graph filters
+
+ {renderPresetChips(graphPresetItems, "graph")}
+ >
+ )}
+ {/* Utilities above the field list: "Fields shown" is a checkbox per column — a dozen
+ rows on a wide table — so anything below it starts a long scroll down, and refresh,
+ export and reset are what this sheet gets opened for far more often. */}
+
+ {
+ onResetFilters();
+ onClose();
+ }}
+ sx={{ minHeight: 48 }}
+ >
+
+
+
+
+
+ {onEditGraphFilters && (
+ run(onEditGraphFilters)} sx={{ minHeight: 48 }}>
+
+
+
+
+
+ )}
+ {exportEnabled && (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+ run(onViewApiResponse)} sx={{ minHeight: 48 }}>
+
+
+
+
+
+ {
+ onRefresh();
+ onClose();
+ }}
+ sx={{ minHeight: 48 }}
+ >
+
+
+
+
+
+ {columnItems.length > 0 && (
+ <>
+
+
+ Fields shown
+
+ {columnItems.map((column) => (
+ onToggleColumn(column.id, column.visible)}
+ sx={{ minHeight: 44, py: 0 }}
+ >
+
+
+
+ ))}
+ >
+ )}
+ {onPageSizeChange && pageSizeOptions.length > 0 && (
+ <>
+
+ Rows per page
+
+
+ {pageSizeOptions.map((option) => {
+ const active = option === pageSize;
+ return (
+ : undefined}
+ onClick={() => onPageSizeChange(option)}
+ sx={{ height: 36, borderRadius: 999 }}
+ />
+ );
+ })}
+
+ >
+ )}
+
+ );
+};
diff --git a/src/components/CippTable/toolbar-primitives.js b/src/components/CippTable/toolbar-primitives.js
new file mode 100644
index 000000000000..cfea4a82d585
--- /dev/null
+++ b/src/components/CippTable/toolbar-primitives.js
@@ -0,0 +1,103 @@
+import { styled, alpha } from '@mui/material/styles'
+import { Button, IconButton, InputBase, Paper } from '@mui/material'
+
+// shared toolbar styling for the desktop table toolbar and the mobile card controls bar
+
+export const ModernSearchContainer = styled(Paper)(({ theme }) => ({
+ display: 'flex',
+ alignItems: 'center',
+ width: '100%',
+ maxWidth: '300px',
+ minWidth: '200px',
+ height: '40px',
+ backgroundColor: theme.palette.mode === 'dark' ? '#2A2D3A' : '#F8F9FA',
+ border: `1px solid ${theme.palette.mode === 'dark' ? '#404040' : '#E0E0E0'}`,
+ borderRadius: '8px',
+ padding: '0 12px',
+ '&:hover': {
+ borderColor: theme.palette.primary.main,
+ },
+ '&:focus-within': {
+ borderColor: theme.palette.primary.main,
+ boxShadow: `0 0 0 2px ${alpha(theme.palette.primary.main, 0.2)}`,
+ },
+ [theme.breakpoints.down('md')]: {
+ minWidth: '0',
+ maxWidth: 'none',
+ flex: 1,
+ },
+}))
+
+export const ModernSearchInput = styled(InputBase)(({ theme }) => ({
+ marginLeft: theme.spacing(1),
+ flex: 1,
+ fontSize: '14px',
+ '& .MuiInputBase-input': {
+ padding: '8px 0',
+ '&::placeholder': {
+ color: theme.palette.text.secondary,
+ opacity: 0.7,
+ },
+ },
+}))
+
+export const ModernButton = styled(Button)(({ theme }) => ({
+ height: '40px',
+ borderRadius: '8px',
+ textTransform: 'none',
+ fontWeight: 500,
+ fontSize: '14px',
+ padding: '8px 16px',
+ backgroundColor: theme.palette.mode === 'dark' ? '#2A2D3A' : '#F8F9FA',
+ border: `1px solid ${theme.palette.mode === 'dark' ? '#404040' : '#E0E0E0'}`,
+ color: theme.palette.text.primary,
+ minWidth: 'auto',
+ whiteSpace: 'nowrap',
+ '&:hover': {
+ backgroundColor: theme.palette.mode === 'dark' ? '#363A4A' : '#F0F0F0',
+ borderColor: theme.palette.primary.main,
+ },
+ '& .MuiButton-startIcon': {
+ marginRight: '8px',
+ },
+ '& .MuiButton-endIcon': {
+ marginLeft: '8px',
+ },
+ [theme.breakpoints.down('md')]: {
+ padding: '8px 12px',
+ fontSize: '13px',
+ '& .MuiButton-startIcon': {
+ marginRight: '6px',
+ },
+ '& .MuiButton-endIcon': {
+ marginLeft: '6px',
+ },
+ },
+ [theme.breakpoints.down('sm')]: {
+ padding: '8px 10px',
+ fontSize: '12px',
+ '& .MuiButton-startIcon': {
+ marginRight: '4px',
+ },
+ '& .MuiButton-endIcon': {
+ marginLeft: '4px',
+ },
+ },
+}))
+
+// tonal icon button matching ModernButton, 44px for phone touch targets
+export const ModernIconButton = styled(IconButton)(({ theme }) => ({
+ width: '44px',
+ height: '44px',
+ borderRadius: '8px',
+ backgroundColor: theme.palette.mode === 'dark' ? '#2A2D3A' : '#F8F9FA',
+ border: `1px solid ${theme.palette.mode === 'dark' ? '#404040' : '#E0E0E0'}`,
+ color: theme.palette.text.primary,
+ flexShrink: 0,
+ '&:hover': {
+ backgroundColor: theme.palette.mode === 'dark' ? '#363A4A' : '#F0F0F0',
+ borderColor: theme.palette.primary.main,
+ },
+}))
+
+export const RefreshButton = styled(IconButton)(({ theme }) => ({}))
diff --git a/src/components/CippTable/util-columnsFromAPI.js b/src/components/CippTable/util-columnsFromAPI.js
index ad170fc3ddff..e530b18f3ab6 100644
--- a/src/components/CippTable/util-columnsFromAPI.js
+++ b/src/components/CippTable/util-columnsFromAPI.js
@@ -32,12 +32,13 @@ const TIME_AGO_NAMES = new Set([
'Date', 'WhenCreated', 'WhenChanged', 'CreationTime', 'renewalDate',
'commitmentTerm.renewalConfiguration.renewalDate', 'purchaseDate', 'NextOccurrence',
'LastOccurrence', 'NotBefore', 'NotAfter', 'latestDataCollection',
- 'requestDate', 'reviewedDate', 'GeneratedAt',
+ 'requestDate', 'reviewedDate', 'GeneratedAt', 'RecordedAt',
])
const MATCH_DATE_TIME = /([dD]ate[tT]ime|[Ee]xpiration|[Tt]imestamp|[sS]tart[Dd]ate)/
const ABSOLUTE_DATE_NAMES = new Set([
'WindowStart', 'WindowEnd', 'CreatedUtc', 'DownloadedUtc', 'ProcessedUtc',
'NextAttemptUtc', 'LastErrorUtc', 'LastPolledUtc',
+ 'QueuedUtc', 'StartedUtc', 'CompletedUtc',
])
const isDateTimeColumn = (key) =>
TIME_AGO_NAMES.has(key) || ABSOLUTE_DATE_NAMES.has(key) || MATCH_DATE_TIME.test(key)
@@ -294,7 +295,7 @@ export const utilColumnsFromAPI = (dataArray) => {
sampleValue,
values: valuesForColumn,
getValue: (row) => resolveValue(row),
- dataArray: filterSample,
+ dataArray,
}),
Cell: ({ row }) => {
const value = resolveValue(row.original)
diff --git a/src/components/CippTable/util-mobile-card-slots.js b/src/components/CippTable/util-mobile-card-slots.js
new file mode 100644
index 000000000000..fcfead619804
--- /dev/null
+++ b/src/components/CippTable/util-mobile-card-slots.js
@@ -0,0 +1,143 @@
+// Pure slotting function for the mobile card list: decides which visible columns become
+// the card title, subtitle, status chips, and detail rows. Runs unattended across every
+// table page, so the rules are deliberate:
+//
+// primary — first NAME_FIELDS match, else first non-status textual column, else the
+// first column. Never naively "first column": the users page's first
+// simpleColumn is accountEnabled, which would title every card "Yes".
+// secondary — first IDENTIFIER_FIELDS match that isn't the primary.
+// chips — up to 3 status-like columns (boolean sortingFn, known status ids, or
+// small select filters).
+// details — up to 3 of whatever remains, in simpleColumns order.
+// rest — everything else, surfaced as "+N more fields" -> detail drawer.
+//
+// Pages that know better pass mobileCard={{primary, secondary, chips, details}} to
+// override any slot; ids not present in the visible columns are ignored.
+
+const NAME_FIELDS = [
+ "displayName",
+ "DisplayName",
+ "Name",
+ "name",
+ "Title",
+ "title",
+ "deviceName",
+ "hostname",
+ "TenantName",
+ "Tenant",
+ "subject",
+ "RowKey",
+];
+
+const IDENTIFIER_FIELDS = [
+ "userPrincipalName",
+ "UPN",
+ "mail",
+ "primarySmtpAddress",
+ "defaultDomainName",
+ "serialNumber",
+ "id",
+ "RowKey",
+];
+
+// Known enum-ish ids that read as status even when their filter variant doesn't say so.
+// accountEnabled is here because get-cipp-filter-variant gives it an explicit select case
+// with alphanumeric sorting and no options — none of the generic signals fire for it.
+const STATUS_FIELDS = new Set(
+ [
+ "severity",
+ "risk",
+ "result",
+ "status",
+ "state",
+ "compliancestate",
+ "risklevel",
+ "riskstate",
+ "usertype",
+ "outcome",
+ "healthstate",
+ "isenabled",
+ "enabled",
+ "accountenabled",
+ ].map((f) => f.toLowerCase())
+);
+
+const columnId = (col) => col?.id ?? col?.columnDef?.id ?? col?.accessorKey;
+const columnDef = (col) => col?.columnDef ?? col;
+
+export const isStatusLike = (col) => {
+ const def = columnDef(col);
+ if (def?.sortingFn === "boolean") return true;
+ const id = String(columnId(col) ?? "").toLowerCase();
+ if (STATUS_FIELDS.has(id)) return true;
+ if (
+ def?.filterVariant === "select" &&
+ Array.isArray(def?.filterSelectOptions) &&
+ def.filterSelectOptions.length > 0 &&
+ def.filterSelectOptions.length <= 6
+ ) {
+ return true;
+ }
+ return false;
+};
+
+const firstMatch = (columns, priorityList, exclude = new Set()) => {
+ for (const fieldName of priorityList) {
+ const match = columns.find((col) => columnId(col) === fieldName && !exclude.has(col));
+ if (match) return match;
+ }
+ return null;
+};
+
+/**
+ * @param {Array} visibleColumns columns from table.getVisibleLeafColumns() (or any array of
+ * objects carrying id + columnDef); mrt-* utility columns are filtered out here.
+ * @param {Object} [override] optional mobileCard prop: {primary, secondary, chips, details} as ids.
+ * @returns {{primary, secondary, chips: [], details: [], rest: [], restCount: number}}
+ * primary/secondary are columns (or null); chips/details/rest are column arrays.
+ */
+export const getMobileCardSlots = (visibleColumns, override = {}) => {
+ const columns = (visibleColumns ?? []).filter(
+ (col) => !String(columnId(col) ?? "").startsWith("mrt-")
+ );
+
+ if (columns.length === 0) {
+ return { primary: null, secondary: null, chips: [], details: [], rest: [], restCount: 0 };
+ }
+
+ const byId = (id) => columns.find((col) => columnId(col) === id);
+ const used = new Set();
+
+ const primary =
+ (override.primary && byId(override.primary)) ||
+ firstMatch(columns, NAME_FIELDS) ||
+ columns.find((col) => !isStatusLike(col)) ||
+ columns[0];
+ used.add(primary);
+
+ const secondary =
+ (override.secondary && override.secondary !== columnId(primary) && byId(override.secondary)) ||
+ firstMatch(columns, IDENTIFIER_FIELDS, used) ||
+ null;
+ if (secondary) used.add(secondary);
+
+ let chips;
+ if (Array.isArray(override.chips)) {
+ chips = override.chips.map(byId).filter((col) => col && !used.has(col));
+ } else {
+ chips = columns.filter((col) => !used.has(col) && isStatusLike(col)).slice(0, 3);
+ }
+ chips.forEach((col) => used.add(col));
+
+ let details;
+ if (Array.isArray(override.details)) {
+ details = override.details.map(byId).filter((col) => col && !used.has(col));
+ } else {
+ details = columns.filter((col) => !used.has(col)).slice(0, 3);
+ }
+ details.forEach((col) => used.add(col));
+
+ const rest = columns.filter((col) => !used.has(col));
+
+ return { primary, secondary, chips, details, rest, restCount: rest.length };
+};
diff --git a/src/components/CippTable/util-subTables.js b/src/components/CippTable/util-subTables.js
new file mode 100644
index 000000000000..60b256bc4e33
--- /dev/null
+++ b/src/components/CippTable/util-subTables.js
@@ -0,0 +1,75 @@
+const hasOwn = (row, key) =>
+ Boolean(key) && row != null && typeof row === 'object' && Object.prototype.hasOwnProperty.call(row, key)
+
+const hasPopulatedColumnValue = (row, columnId) => {
+ if (!hasOwn(row, columnId)) {
+ return false
+ }
+ const value = row[columnId]
+ if (value == null) {
+ return false
+ }
+ if (typeof value === 'string') {
+ return value.trim().length > 0
+ }
+ if (Array.isArray(value)) {
+ return value.length > 0
+ }
+ return true
+}
+
+export const dataHasPopulatedColumn = (data, columnId) =>
+ Boolean(columnId) &&
+ Array.isArray(data) &&
+ data.some((row) => hasPopulatedColumnValue(row, columnId))
+
+export const subTableIsSelected = (sub, selectedIds) => {
+ if (!sub?.id) {
+ return false
+ }
+ if (!Array.isArray(selectedIds) || selectedIds.length === 0) {
+ return true
+ }
+ return selectedIds.includes(sub.id)
+}
+
+export const subTableShowsCachedColumn = (sub, data) =>
+ Boolean(sub?.cachedColumn) && dataHasPopulatedColumn(data, sub.cachedColumn)
+
+export const resolveSubTableSimpleColumns = (simpleColumns, subTables, data) => {
+ if (!Array.isArray(simpleColumns) || !Array.isArray(subTables) || subTables.length === 0) {
+ return simpleColumns
+ }
+
+ return simpleColumns.map((id) => {
+ const sub = subTables.find((item) => item.id === id)
+ if (sub && subTableShowsCachedColumn(sub, data)) {
+ return sub.cachedColumn
+ }
+ return id
+ })
+}
+
+export const getSubTableDisplayColumnIds = (subTables, simpleColumns, data) => {
+ if (!Array.isArray(subTables) || subTables.length === 0) {
+ return []
+ }
+ const ids = []
+ for (const sub of subTables) {
+ if (!subTableIsSelected(sub, simpleColumns)) {
+ continue
+ }
+ const columnId = subTableShowsCachedColumn(sub, data) ? sub.cachedColumn : sub.id
+ if (columnId) {
+ ids.push(columnId)
+ }
+ }
+ return ids
+}
+
+export const columnOrderHasStaleIds = (columnOrder, displayColumnIds) => {
+ const displayIdSet = new Set(displayColumnIds)
+ return (columnOrder ?? []).some(
+ (id) => id && !String(id).startsWith('mrt-') && !displayIdSet.has(id)
+ )
+}
diff --git a/src/components/CippTable/util-tablemode.js b/src/components/CippTable/util-tablemode.js
index 8e5120ebb1a5..92fbbcd2abe2 100644
--- a/src/components/CippTable/util-tablemode.js
+++ b/src/components/CippTable/util-tablemode.js
@@ -1,3 +1,7 @@
+// Card mode renders its own list, so a huge desktop tablePageSize preference must not
+// become that many unvirtualized cards. CippMobileCardList grows pageSize from here.
+const MOBILE_PAGE_SIZE_CAP = 50
+
export const utilTableMode = (
columnVisibility,
mode,
@@ -6,7 +10,9 @@ export const utilTableMode = (
offCanvas,
onChange,
maxHeightOffset = '380px',
- settings = {}
+ settings = {},
+ viewMode = 'table',
+ narrowTable = false
) => {
if (mode === true) {
return {
@@ -42,20 +48,34 @@ export const utilTableMode = (
},
}
} else {
+ const configuredPageSize = settings?.tablePageSize?.value
+ ? parseInt(settings?.tablePageSize?.value, 10)
+ : 25
+ const isCards = viewMode === 'cards'
+
return {
enableRowSelection: actions || onChange ? true : false,
enableRowActions: actions ? true : false,
enableSelectAll: true,
enableFacetedValues: true,
enableColumnFilterModes: true,
- enableStickyHeader: true,
+ enableStickyHeader: !isCards,
selectAllMode: 'all',
- enableColumnPinning: true,
+ enableColumnPinning: !isCards,
muiPaginationProps: {
rowsPerPageOptions: [25, 50, 100, 250, 500],
+ // a full footer wraps below MRT's 720px pivot, the extra row scrolls the page chrome
+ ...(narrowTable && {
+ showRowsPerPage: false,
+ showFirstButton: false,
+ showLastButton: false,
+ }),
},
muiTableContainerProps: {
- sx: { maxHeight: `calc(100vh - ${maxHeightOffset})` },
+ // offset numbers are tuned against desktop chrome, narrow viewports page-scroll
+ sx: {
+ maxHeight: narrowTable ? 'none' : `calc(100vh - ${maxHeightOffset})`,
+ },
},
displayColumnDefOptions: {
'mrt-row-actions': {
@@ -71,15 +91,17 @@ export const utilTableMode = (
showGlobalFilter: true,
density: 'compact',
pagination: {
- pageSize: settings?.tablePageSize?.value
- ? parseInt(settings?.tablePageSize?.value, 10)
- : 25,
+ pageSize: isCards
+ ? Math.min(configuredPageSize, MOBILE_PAGE_SIZE_CAP)
+ : configuredPageSize,
pageIndex: 0,
},
- columnPinning: {
- left: ['mrt-row-select'],
- right: ['mrt-row-actions'],
- },
+ ...(!isCards && {
+ columnPinning: {
+ left: ['mrt-row-select'],
+ right: ['mrt-row-actions'],
+ },
+ }),
},
}
}
diff --git a/src/components/CippTestDetail/CippTestDetailOffCanvas.jsx b/src/components/CippTestDetail/CippTestDetailOffCanvas.jsx
index 45e153d2ec5c..7bed81eb8768 100644
--- a/src/components/CippTestDetail/CippTestDetailOffCanvas.jsx
+++ b/src/components/CippTestDetail/CippTestDetailOffCanvas.jsx
@@ -144,16 +144,16 @@ export const CippTestDetailOffCanvas = ({ row }) => {
+ {/* short label + chip pairs: full-width rows left 80% of a phone empty — 2x2 there,
+ the same 4-across strip on desktop. two-up by design: mobile-layout-ok */}
({
xs: `1px solid ${theme.palette.divider}`,
md: "none",
}),
- borderRight: (theme) => ({
- md: `1px solid ${theme.palette.divider}`,
- }),
+ borderRight: (theme) => `1px solid ${theme.palette.divider}`,
}}
>
@@ -167,8 +167,9 @@ export const CippTestDetailOffCanvas = ({ row }) => {
+ {/* two-up by design: mobile-layout-ok */}
({
xs: `1px solid ${theme.palette.divider}`,
@@ -194,16 +195,11 @@ export const CippTestDetailOffCanvas = ({ row }) => {
+ {/* two-up by design: mobile-layout-ok */}
({
- xs: `1px solid ${theme.palette.divider}`,
- md: "none",
- }),
- borderRight: (theme) => ({
- md: `1px solid ${theme.palette.divider}`,
- }),
+ borderRight: (theme) => `1px solid ${theme.palette.divider}`,
}}
>
@@ -221,12 +217,8 @@ export const CippTestDetailOffCanvas = ({ row }) => {
-
+ {/* two-up by design: mobile-layout-ok */}
+
diff --git a/src/components/CippWizard/CippAddTenantTypeSelection.jsx b/src/components/CippWizard/CippAddTenantTypeSelection.jsx
index f42c0d6d632f..27b3bf210c3a 100644
--- a/src/components/CippWizard/CippAddTenantTypeSelection.jsx
+++ b/src/components/CippWizard/CippAddTenantTypeSelection.jsx
@@ -9,19 +9,16 @@ export const CippAddTenantTypeSelection = (props) => {
const [selectedOption, setSelectedOption] = useState(null)
- // Fetch host tenant organization to check partnerTenantType.
- // No tenantFilter means the backend defaults to $env:TenantID (the CIPP host tenant).
+ // Ask the backend whether this CIPP instance runs on a partner tenant. Deliberately not a
+ // direct Graph call: the tenant-scoped route is denied for custom roles that block the
+ // partner tenant, which greys out the partner-only options below for roles that are
+ // otherwise fully permitted. ListPartnerTenantInfo pins the lookup to the host tenant.
const organization = ApiGetCall({
- url: '/api/ListGraphRequest',
- queryKey: 'ListGraphRequest-organization-partnerTenantType',
- data: {
- Endpoint: 'organization',
- $select: 'partnerTenantType,displayName',
- },
+ url: '/api/ListPartnerTenantInfo',
+ queryKey: 'ListPartnerTenantInfo',
})
- const partnerTenantType = organization.data?.Results?.[0]?.partnerTenantType
- const isPartner = organization.isSuccess && Boolean(partnerTenantType)
+ const isPartner = organization.isSuccess && Boolean(organization.data?.isPartnerTenant)
const partnerCheckComplete = organization.isSuccess || organization.isError
// Register the tenantType field in react-hook-form
diff --git a/src/components/CippWizard/CippIntunePolicy.jsx b/src/components/CippWizard/CippIntunePolicy.jsx
index 10d46a1b7e83..0325ced59f3a 100644
--- a/src/components/CippWizard/CippIntunePolicy.jsx
+++ b/src/components/CippWizard/CippIntunePolicy.jsx
@@ -185,7 +185,7 @@ export const CippIntunePolicy = (props) => {
return null
}
return filteredPlaceholders.map((placeholder) => (
-
+
{selectedTenants.map((tenant, idx) => (
{
const { values: initialValues, onPreviousStep, onNextStep } = props;
const [values, setValues] = useState(initialValues);
@@ -210,20 +211,14 @@ export const CippPSACredentialsStep = (props) => {
)}
>
-
+
Back
Next Step
-
+
);
diff --git a/src/components/CippWizard/CippPSASyncOptions.jsx b/src/components/CippWizard/CippPSASyncOptions.jsx
index 146d5b26279e..a5ee2e01e693 100644
--- a/src/components/CippWizard/CippPSASyncOptions.jsx
+++ b/src/components/CippWizard/CippPSASyncOptions.jsx
@@ -12,6 +12,7 @@ import {
TextField,
Typography,
} from "@mui/material";
+import { CippWizardActionsRow } from "./CippWizardActionsRow";
const options = [
{
@@ -147,14 +148,14 @@ export const CippPSASyncOptions = (props) => {
>
)}
-
+
Back
Next Step
-
+
);
diff --git a/src/components/CippWizard/CippSAMDeploy.jsx b/src/components/CippWizard/CippSAMDeploy.jsx
index d38d0f66ddf2..8507fac4881a 100644
--- a/src/components/CippWizard/CippSAMDeploy.jsx
+++ b/src/components/CippWizard/CippSAMDeploy.jsx
@@ -100,6 +100,15 @@ export const CippSAMDeploy = (props) => {
Multi-factor authentication enabled for the CIPP Service Account, with no trusted
locations or other exclusions.
+
+ Device code sign-in permitted in your partner tenant. Security defaults and Conditional
+ Access authentication flow policies can block it, which will stop this step from
+ completing.
+
+
+
+ This step only creates the CIPP-SAM application registration. The token CIPP runs on is
+ created by the sign-in on the next step.
{authStatus.error && (
diff --git a/src/components/CippWizard/CippTenantModeDeploy.jsx b/src/components/CippWizard/CippTenantModeDeploy.jsx
index d0736b8c5c2e..b31df79683d6 100644
--- a/src/components/CippWizard/CippTenantModeDeploy.jsx
+++ b/src/components/CippWizard/CippTenantModeDeploy.jsx
@@ -1,5 +1,6 @@
import { useEffect } from "react";
import {
+ Alert,
Stack,
Box,
Typography,
@@ -35,6 +36,33 @@ export const CippTenantModeDeploy = (props) => {
waiting: true,
});
+ // The application step mints a client secret and this step uses it moments later, but Entra
+ // can take minutes to activate a new secret. Poll until it is usable so the wait happens
+ // here, rather than the sign-in appearing to work and then failing on the token exchange
+ // with an "invalid client secret" that looks like the app was created wrong.
+ const samSecret = ApiGetCall({
+ url: `/api/ExecSamSecretStatus`,
+ queryKey: "samSecretStatus",
+ waiting: true,
+ staleTime: 0,
+ });
+ const samSecretReady = samSecret.data?.ready === true;
+ const samSecretPropagating = samSecret.data?.reason === "propagating";
+ const {
+ isSuccess: samSecretLoaded,
+ dataUpdatedAt: samSecretUpdatedAt,
+ refetch: refetchSamSecret,
+ } = samSecret;
+
+ // Re-check on a timer rather than a fixed refetchInterval so polling stops once the secret
+ // is usable - there is nothing left to wait for at that point.
+ useEffect(() => {
+ if (samSecretLoaded && !samSecretReady) {
+ const timer = setTimeout(() => refetchSamSecret(), 15000);
+ return () => clearTimeout(timer);
+ }
+ }, [samSecretLoaded, samSecretUpdatedAt, samSecretReady, refetchSamSecret]);
+
useEffect(() => {
if (updateRefreshToken.isSuccess) {
formControl.setValue("GDAPAuth", true);
@@ -201,8 +229,24 @@ export const CippTenantModeDeploy = (props) => {
)}
+ {samSecretLoaded && !samSecretReady && (
+
+ {samSecretPropagating ? (
+ <>
+ Waiting for Microsoft to activate the application secret created in the previous
+ step. Signing in before it is active fails with an invalid client secret error, so
+ this step unlocks on its own once it is ready - usually within a few minutes.
+ Nothing needs to be recreated.
+ >
+ ) : (
+ samSecret.data?.message
+ )}
+
+ )}
+
{
const updatedTokenData = {
...tokenData,
diff --git a/src/components/CippWizard/CippWizard.jsx b/src/components/CippWizard/CippWizard.jsx
index 22f24de234bc..0c35ce677d14 100644
--- a/src/components/CippWizard/CippWizard.jsx
+++ b/src/components/CippWizard/CippWizard.jsx
@@ -34,9 +34,14 @@ export const CippWizard = (props) => {
setActiveStep((prevState) => (prevState > 0 ? prevState - 1 : prevState));
}, []);
+ // Counts against the VISIBLE steps. `steps` is the unfiltered prop — the onboarding
+ // wizard passes 14 and shows 3-7 — so clamping against it let activeStep run past the
+ // end of stepsWithVisibility, and the render below then read `.component` of undefined.
const handleNext = useCallback(() => {
- setActiveStep((prevState) => (prevState < steps.length - 1 ? prevState + 1 : prevState));
- }, []);
+ setActiveStep((prevState) =>
+ prevState < stepsWithVisibility.length - 1 ? prevState + 1 : prevState
+ );
+ }, [stepsWithVisibility.length]);
const content = useMemo(() => {
const currentStep = stepsWithVisibility[activeStep];
@@ -57,7 +62,7 @@ export const CippWizard = (props) => {
{...currentStep.componentProps}
/>
);
- }, [activeStep, handleNext, handleBack, stepsWithVisibility, formControl]);
+ }, [activeStep, handleNext, handleBack, stepsWithVisibility, formControl, postUrl]);
// Get the maxWidth for the current step, fallback to global setting
const currentStepMaxWidth = useMemo(() => {
@@ -85,7 +90,9 @@ export const CippWizard = (props) => {
) : (
-
+ {/* 48px under a three-line stepper is right; under the compact mobile header it
+ is dead space. */}
+
{
steps={stepsWithVisibility}
/>
- {content}
+ {/* Below md this Container clamps nothing — maxWidth is md/lg — and its
+ gutters only duplicate the ones CardContent already pays. disableGutters
+ with px at md restores exactly Container's own value from md up. */}
+
+ {content}
+
diff --git a/src/components/CippWizard/CippWizardActionsRow.jsx b/src/components/CippWizard/CippWizardActionsRow.jsx
new file mode 100644
index 000000000000..0641f9dbb199
--- /dev/null
+++ b/src/components/CippWizard/CippWizardActionsRow.jsx
@@ -0,0 +1,47 @@
+import PropTypes from "prop-types";
+import { Stack } from "@mui/material";
+
+/**
+ * The Back / Next / Submit row shared by the wizard step buttons and the three steps that
+ * roll their own.
+ *
+ * Presentational only — no behaviour, because the four call sites disagree about what the
+ * buttons DO (some gate Next on form validity, some own their submit) and only agree about
+ * how the row should sit.
+ *
+ * Below md the row stacks in `column-reverse`, which puts the primary action at the top and
+ * Close at the bottom. Two details are load-bearing:
+ * - `alignItems: stretch`, or a column would shrink every child to its content width.
+ * - the descendant selector rather than per-button `fullWidth`: the Submit button is
+ * wrapped in its own
+ )
+ })}
{
+ const { onNextStep, formControl, currentStep, onPreviousStep } = props
+
+ const [selectedOption, setSelectedOption] = useState(() =>
+ formControl.getValues('deploymentType')
+ )
+
+ // Register the deploymentType field in react-hook-form
+ formControl.register('deploymentType', {
+ required: true,
+ })
+
+ useEffect(() => {
+ if (formControl.getValues('deploymentType')) {
+ formControl.trigger('deploymentType')
+ }
+ }, [formControl])
+
+ const handleOptionClick = (value) => {
+ setSelectedOption(value)
+ formControl.setValue('deploymentType', value)
+
+ // Clear the other path's fields so switching back and forth doesn't submit
+ // stale device data or keep its validation rules active
+ if (value === 'autopilot') {
+ formControl.unregister('devicePrepData')
+ formControl.unregister('overwriteExisting')
+ } else if (value === 'devicePrep') {
+ formControl.unregister('autopilotData')
+ formControl.unregister('GroupName')
+ }
+
+ formControl.trigger()
+ }
+
+ const options = [
+ {
+ value: 'autopilot',
+ label: 'Windows Autopilot',
+ description:
+ 'Upload devices to Windows Autopilot using their serial number, product ID or hardware hash.',
+ icon: ,
+ },
+ {
+ value: 'devicePrep',
+ label: 'Device Preparation (Corporate Identifiers)',
+ description:
+ 'Upload corporate device identifiers (manufacturer, model and serial number) so devices are recognized as corporate-owned and can enroll using Windows Autopilot device preparation.',
+ icon: ,
+ },
+ ]
+
+ return (
+
+
+ Select Deployment Type
+
+ Choose how you want to register the devices for this tenant.
+
+
+
+ {options.map((option) => {
+ const isSelected = selectedOption === option.value
+
+ return (
+ handleOptionClick(option.value)}
+ variant="outlined"
+ sx={{
+ cursor: 'pointer',
+ ...(isSelected && {
+ boxShadow: (theme) =>
+ `0px 0px 0px 2px ${theme.palette.primary.main}`,
+ }),
+ '&:hover': {
+ ...(isSelected ? {} : { boxShadow: 8 }),
+ },
+ }}
+ >
+
+
+
+ {option.icon}
+
+
+ {option.label}
+
+ {option.description}
+
+
+
+
+
+ )
+ })}
+
+
+
+ )
+}
+
+export default CippWizardAutopilotTypeSelection
diff --git a/src/components/CippWizard/CippWizardDevicePrepImport.jsx b/src/components/CippWizard/CippWizardDevicePrepImport.jsx
new file mode 100644
index 000000000000..157b525977b0
--- /dev/null
+++ b/src/components/CippWizard/CippWizardDevicePrepImport.jsx
@@ -0,0 +1,619 @@
+import {
+ Button,
+ Link,
+ Stack,
+ Box,
+ Typography,
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ TextField,
+ Alert,
+ Paper,
+ IconButton,
+} from '@mui/material'
+import { CippWizardStepButtons } from './CippWizardStepButtons'
+import CippFormComponent from '../CippComponents/CippFormComponent'
+import { CippDataTable } from '../CippTable/CippDataTable'
+import { useWatch } from 'react-hook-form'
+import { Delete, FileDownload, Upload, Add } from '@mui/icons-material'
+import { useEffect, useState } from 'react'
+import React from 'react'
+import { useIsMobileLayout } from '../../hooks/use-breakpoint'
+
+// Modified version of CippWizardAutopilotImport for corporate device identifiers
+// (Autopilot device preparation): every device is a manufacturer, model and serial
+// number triplet that Graph combines into a single comma-separated identifier, so
+// all three fields are required and none of them may contain a comma.
+export const CippWizardDevicePrepImport = (props) => {
+ const {
+ onNextStep,
+ formControl,
+ currentStep,
+ onPreviousStep,
+ fields,
+ name,
+ fileName = 'template',
+ } = props
+ const tableData = useWatch({ control: formControl.control, name: name })
+ // Seed from the form so navigating back to this step keeps the imported rows
+ const [newTableData, setTableData] = useState(
+ () => formControl.getValues(name) || []
+ )
+ const fileInputRef = React.useRef(null)
+ const [manualDialogOpen, setManualDialogOpen] = useState(false)
+ const [manualInputs, setManualInputs] = useState([{}])
+ const inputRefs = React.useRef([])
+ const isMobile = useIsMobileLayout()
+ const [validationErrors, setValidationErrors] = useState([])
+ const [importErrors, setImportErrors] = useState([])
+
+ // At least one identifier is needed before the wizard can continue
+ formControl.register(name, {
+ validate: (value) => Array.isArray(value) && value.length > 0,
+ })
+
+ const handleRemoveItem = (row) => {
+ if (row === undefined) return false
+ const index = tableData?.findIndex((item) => item === row)
+ const newTableData = [...tableData]
+ newTableData.splice(index, 1)
+ setTableData(newTableData)
+ }
+
+ const collectRowErrors = (rows) => {
+ const errors = []
+ const seenIdentifiers = new Set()
+
+ rows.forEach((row, index) => {
+ const missingFields = fields.filter(
+ (field) =>
+ !row[field.propertyName] || row[field.propertyName].trim() === ''
+ )
+ if (missingFields.length > 0) {
+ errors.push(
+ `Row ${index + 1}: ${missingFields.map((f) => f.friendlyName).join(', ')} ${
+ missingFields.length === 1 ? 'is' : 'are'
+ } required`
+ )
+ return
+ }
+
+ const commaFields = fields.filter((field) =>
+ row[field.propertyName].includes(',')
+ )
+ if (commaFields.length > 0) {
+ errors.push(
+ `Row ${index + 1}: ${commaFields
+ .map((f) => f.friendlyName)
+ .join(', ')} may not contain a comma`
+ )
+ return
+ }
+
+ const identifier = fields
+ .map((field) => row[field.propertyName].trim().toLowerCase())
+ .join(',')
+ if (seenIdentifiers.has(identifier)) {
+ errors.push(`Row ${index + 1}: Duplicate device "${identifier}"`)
+ }
+ seenIdentifiers.add(identifier)
+ })
+
+ return errors
+ }
+
+ const handleFileSelect = (event) => {
+ const file = event.target.files[0]
+ if (file) {
+ const reader = new FileReader()
+ reader.onload = (e) => {
+ const text = e.target.result
+ const lines = text.split('\n')
+ const firstLine = lines[0].split(',').map((header) => header.trim())
+
+ // Check if this is a headerless CSV (no recognizable headers). The Intune
+ // portal's corporate identifier CSV has no header row.
+ const hasHeaders = firstLine.some((header) => {
+ return fields.some(
+ (field) =>
+ header === field.propertyName ||
+ header === field.friendlyName ||
+ (field.alternativePropertyNames &&
+ field.alternativePropertyNames.includes(header))
+ )
+ })
+
+ let headers, headerMapping
+
+ if (hasHeaders) {
+ headers = firstLine
+
+ // Create mapping for property names and alternative property names
+ headerMapping = {}
+ fields.forEach((field) => {
+ headerMapping[field.propertyName] = field.propertyName
+ headerMapping[field.friendlyName] = field.propertyName
+ if (field.alternativePropertyNames) {
+ field.alternativePropertyNames.forEach((altName) => {
+ headerMapping[altName] = field.propertyName
+ })
+ }
+ })
+
+ // All three columns are required for corporate identifiers
+ const missingColumns = fields.filter((field) => {
+ const hasPropertyName = headers.includes(field.propertyName)
+ const hasFriendlyName = headers.includes(field.friendlyName)
+ const hasAlternativeName = field.alternativePropertyNames
+ ? field.alternativePropertyNames.some((altName) =>
+ headers.includes(altName)
+ )
+ : false
+ return !hasPropertyName && !hasFriendlyName && !hasAlternativeName
+ })
+
+ if (missingColumns.length > 0) {
+ const missingFormats = missingColumns
+ .map((f) => {
+ const formats = [f.propertyName, f.friendlyName]
+ if (f.alternativePropertyNames) {
+ formats.push(...f.alternativePropertyNames)
+ }
+ return `"${formats.join('" or "')}"`
+ })
+ .join(', ')
+ setImportErrors([
+ `CSV is missing required columns: ${missingFormats}`,
+ ])
+ return
+ }
+ } else {
+ // Headerless CSV - assume the Intune portal order: manufacturer, model, serial number
+ headers = fields.map((field) => field.propertyName)
+ headerMapping = {}
+ headers.forEach((header) => {
+ headerMapping[header] = header
+ })
+
+ if (firstLine.length < fields.length) {
+ setImportErrors([
+ `Headerless CSV must have ${fields.length} columns in order: ${fields
+ .map((f) => f.friendlyName)
+ .join(', ')}`,
+ ])
+ return
+ }
+ }
+
+ const data = lines
+ .slice(hasHeaders ? 1 : 0) // Skip first line only if it has headers
+ .filter((line) => line.trim() !== '') // Remove empty lines
+ .map((line) => {
+ const values = line.split(',')
+ const row = fields.reduce((obj, field) => {
+ obj[field.propertyName] = ''
+ return obj
+ }, {})
+ headers.forEach((header, i) => {
+ const propertyName = headerMapping[header]
+ if (propertyName) {
+ row[propertyName] = values[i]?.trim() || ''
+ }
+ })
+ return row
+ })
+
+ const errors = collectRowErrors(data)
+ if (errors.length > 0) {
+ setImportErrors(errors)
+ return
+ }
+
+ setImportErrors([])
+ setTableData(data)
+ formControl.setValue(name, data, { shouldValidate: true })
+ }
+ reader.readAsText(file)
+ }
+ }
+
+ const handleManualInputChange = (rowIndex, field, value) => {
+ setManualInputs((prev) => {
+ const newInputs = [...prev]
+ if (!newInputs[rowIndex]) {
+ newInputs[rowIndex] = {}
+ }
+ newInputs[rowIndex][field] = value
+ return newInputs
+ })
+ }
+
+ const handleAddRow = () => {
+ setManualInputs((prev) => [...prev, {}])
+ }
+
+ const validateRows = (rows) => {
+ const errors = collectRowErrors(
+ rows.filter((row) =>
+ Object.values(row).some((value) => value && value.trim() !== '')
+ )
+ )
+ setValidationErrors(errors)
+ return errors.length === 0
+ }
+
+ const handleManualAdd = () => {
+ const newRows = manualInputs
+ .filter((row) =>
+ Object.values(row).some((value) => value && value.trim() !== '')
+ )
+ .map((row) => {
+ return fields.reduce((obj, field) => {
+ obj[field.propertyName] = row[field.propertyName] || ''
+ return obj
+ }, {})
+ })
+
+ if (newRows.length === 0) {
+ setManualDialogOpen(false)
+ setManualInputs([{}])
+ return
+ }
+
+ if (!validateRows(newRows)) {
+ return
+ }
+
+ const updatedData = [...(tableData || []), ...newRows]
+ setTableData(updatedData)
+ formControl.setValue(name, updatedData, { shouldValidate: true })
+ setManualInputs([{}])
+ setManualDialogOpen(false)
+ }
+
+ const handleDialogClose = () => {
+ setManualDialogOpen(false)
+ setManualInputs([{}])
+ }
+
+ const lastField = fields[fields.length - 1]
+
+ const handleKeyPress = (event, rowIndex) => {
+ if (
+ event.key === 'Enter' &&
+ manualInputs[rowIndex]?.[lastField.propertyName]
+ ) {
+ if (rowIndex === manualInputs.length - 1) {
+ const newRowIndex = manualInputs.length
+ setManualInputs((prev) => [...prev, {}])
+ // Wait for the next render cycle to set focus
+ setTimeout(() => {
+ const newInput =
+ inputRefs.current[newRowIndex]?.[fields[0].propertyName]
+ if (newInput) {
+ newInput.focus()
+ }
+ }, 0)
+ }
+ }
+ }
+
+ const handleRemoveRow = (rowIndex) => {
+ setManualInputs((prev) => prev.filter((_, index) => index !== rowIndex))
+ }
+
+ useEffect(() => {
+ formControl.setValue(name, newTableData, {
+ shouldValidate: true,
+ })
+ }, [newTableData])
+
+ // Add effect to validate rows when manualInputs changes
+ useEffect(() => {
+ validateRows(manualInputs)
+ }, [manualInputs])
+
+ const actions = [
+ {
+ icon: ,
+ label: 'Delete Row',
+ confirmText: 'Are you sure you want to delete this row?',
+ customFunction: handleRemoveItem,
+ noConfirm: true,
+ },
+ ]
+
+ return (
+
+ {importErrors.length > 0 && (
+ setImportErrors([])}>
+
+ The file could not be imported:
+
+ {importErrors.map((error, index) => (
+
+ • {error}
+
+ ))}
+
+ )}
+ f.propertyName)}
+ cardButton={
+
+ f.propertyName).join(',') + '\n'
+ )}`}
+ download={`${fileName}.csv`}
+ startIcon={ }
+ size="small"
+ >
+ Download Template
+
+
+ }
+ onClick={() => fileInputRef.current?.click()}
+ size="small"
+ >
+ Import from CSV
+
+ }
+ onClick={() => setManualDialogOpen(true)}
+ size="small"
+ >
+ Manual Import
+
+
+ }
+ />
+
+
+
+
+ Manual Import
+
+
+ {validationErrors.length > 0 && (
+
+
+ Please fix the following validation errors:
+
+ {validationErrors.map((error, index) => (
+
+ • {error}
+
+ ))}
+
+ )}
+ {manualInputs.map((row, rowIndex) => {
+ // Defined once and placed by either branch, so the two layouts cannot drift.
+ const fieldInputs = fields.map((field) => (
+
+ {
+ if (!inputRefs.current[rowIndex]) {
+ inputRefs.current[rowIndex] = {}
+ }
+ inputRefs.current[rowIndex][field.propertyName] = el
+ }}
+ label={field.friendlyName}
+ value={row[field.propertyName] || ''}
+ onChange={(e) =>
+ handleManualInputChange(
+ rowIndex,
+ field.propertyName,
+ e.target.value
+ )
+ }
+ onKeyDown={(e) =>
+ field.propertyName === lastField.propertyName &&
+ handleKeyPress(e, rowIndex)
+ }
+ fullWidth
+ size="small"
+ />
+
+ ))
+
+ const rowNumber = (
+
+ {rowIndex + 1}
+
+ )
+
+ // Below md one row becomes one card instead of a horizontal scroller.
+ if (isMobile) {
+ return (
+
+
+ {rowNumber}
+
+ Device {rowIndex + 1}
+
+ handleRemoveRow(rowIndex)}
+ disabled={manualInputs.length === 1}
+ color="error"
+ aria-label={`Remove device ${rowIndex + 1}`}
+ >
+
+
+
+ {fieldInputs}
+
+ )
+ }
+
+ return (
+
+ {rowNumber}
+ {fieldInputs}
+ handleRemoveRow(rowIndex)}
+ disabled={manualInputs.length === 1}
+ sx={{
+ minWidth: '48px',
+ height: '40px',
+ fontSize: '24px',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ alignSelf: 'center',
+ mr: 2,
+ }}
+ color="error"
+ >
+ ×
+
+
+ )
+ })}
+
+ value && value.trim() !== ''
+ )
+ }
+ sx={{
+ minWidth: '48px',
+ height: '40px',
+ fontSize: '24px',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ alignSelf: 'center',
+ mr: 2,
+ }}
+ >
+ +
+
+
+
+
+
+ Cancel
+ 0 ||
+ !Object.values(manualInputs[manualInputs.length - 1]).some(
+ (value) => value && value.trim() !== ''
+ )
+ }
+ >
+ Add
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/CippWizard/CippWizardGroupTemplates.jsx b/src/components/CippWizard/CippWizardGroupTemplates.jsx
index 837d2fba4a72..9dd81193d2a5 100644
--- a/src/components/CippWizard/CippWizardGroupTemplates.jsx
+++ b/src/components/CippWizard/CippWizardGroupTemplates.jsx
@@ -12,7 +12,6 @@ export const CippWizardGroupTemplates = (props) => {
const lastAppliedTemplate = useRef(null);
const groupOptions = [
{ label: "Dynamic Group", value: "dynamic" },
- { label: "Dynamic Distribution Group", value: "dynamicDistribution" },
{ label: "Security Group", value: "generic" },
{ label: "Distribution Group", value: "distribution" },
{ label: "Azure Role Group", value: "azureRole" },
diff --git a/src/components/CippWizard/CippWizardOffboarding.jsx b/src/components/CippWizard/CippWizardOffboarding.jsx
index 2fc5947c22bf..eb58cd4fab8d 100644
--- a/src/components/CippWizard/CippWizardOffboarding.jsx
+++ b/src/components/CippWizard/CippWizardOffboarding.jsx
@@ -25,7 +25,8 @@ export const CippWizardOffboarding = (props) => {
const currentTenant = formControl.watch('tenantFilter')
const selectedUsers = useWatch({ control: formControl.control, name: 'user' })
const [showAlert, setShowAlert] = useState(false)
- const userSettingsDefaults = useSettings().userSettingsDefaults
+ const settings = useSettings()
+ const userOffboardingDefaults = settings?.offboardingDefaults
const disableForwarding = useWatch({ control: formControl.control, name: 'disableForwarding' })
const deleteUser = useWatch({ control: formControl.control, name: 'DeleteUser' })
const convertToShared = useWatch({ control: formControl.control, name: 'ConvertToShared' })
@@ -89,25 +90,24 @@ export const CippWizardOffboarding = (props) => {
const tenantDefaults = currentTenant?.addedFields?.offboardingDefaults
if (tenantDefaults) {
- // Apply tenant defaults
+ // Apply tenant defaults; always clear OOO when the blob omits it so user defaults do not leak
Object.entries(tenantDefaults).forEach(([key, value]) => {
formControl.setValue(key, value)
})
- // Set the source indicator
+ formControl.setValue('OOO', tenantDefaults.OOO ?? '')
formControl.setValue('HIDDEN_defaultsSource', 'tenant')
- } else if (userSettingsDefaults?.offboardingDefaults) {
- // Apply user defaults if no tenant defaults
- userSettingsDefaults.offboardingDefaults.forEach((setting) => {
- formControl.setValue(setting.name, setting.value)
+ } else if (userOffboardingDefaults) {
+ Object.entries(userOffboardingDefaults).forEach(([key, value]) => {
+ formControl.setValue(key, value)
})
- // Set the source indicator
+ formControl.setValue('OOO', userOffboardingDefaults.OOO ?? '')
formControl.setValue('HIDDEN_defaultsSource', 'user')
}
// Mark that we've applied defaults for this tenant
formControl.setValue('HIDDEN_appliedDefaultsForTenant', currentTenantId)
}
- }, [currentTenant?.value, userSettingsDefaults, formControl])
+ }, [currentTenant?.value, userOffboardingDefaults, formControl])
useEffect(() => {
if (disableForwarding) {
@@ -123,7 +123,7 @@ export const CippWizardOffboarding = (props) => {
return (
-
+
@@ -266,7 +266,7 @@ export const CippWizardOffboarding = (props) => {
-
+
@@ -326,6 +326,61 @@ export const CippWizardOffboarding = (props) => {
},
}}
/>
+ `${option.displayName} (${option.userPrincipalName})`,
+ valueField: 'id',
+ url: '/api/ListGraphRequest',
+ dataKey: 'Results',
+ tenantFilter: currentTenant ? currentTenant.value : undefined,
+ queryKey: `Offboarding-Users-${currentTenant ? currentTenant.value : 'default'}`,
+ data: {
+ Endpoint: 'users',
+ manualPagination: true,
+ $select: 'id,userPrincipalName,displayName',
+ $count: true,
+ $orderby: 'displayName',
+ $top: 999,
+ },
+ }}
+ />
+ `${option.displayName} (${option.userPrincipalName})`,
+ valueField: 'id',
+ url: '/api/ListGraphRequest',
+ dataKey: 'Results',
+ tenantFilter: currentTenant ? currentTenant.value : undefined,
+ queryKey: `Offboarding-Users-${currentTenant ? currentTenant.value : 'default'}`,
+ data: {
+ Endpoint: 'users',
+ manualPagination: true,
+ $select: 'id,userPrincipalName,displayName',
+ $count: true,
+ $orderby: 'displayName',
+ $top: 999,
+ },
+ }}
+ />
+
+ OneDrive Access
+
{deleteUser && (
When a user is deleted, their OneDrive is retained for 30 days by default unless
@@ -335,7 +390,7 @@ export const CippWizardOffboarding = (props) => {
{
disabled={!!deleteUser}
/>
+
+ Out of Office
+
@@ -420,6 +478,10 @@ export const CippWizardOffboarding = (props) => {
fullWidth
formControl={formControl}
/>
+
+ CIPP %variable% tokens (for example %tenantname%) stay literal here and are
+ resolved when the offboarding job runs. %username% is not the offboarded user.
+
{convertToShared && oversizedMailboxes.length > 0 && (
diff --git a/src/components/CippWizard/CippWizardPage.jsx b/src/components/CippWizard/CippWizardPage.jsx
index 0fa517fb6cc6..6a714493f53b 100644
--- a/src/components/CippWizard/CippWizardPage.jsx
+++ b/src/components/CippWizard/CippWizardPage.jsx
@@ -8,9 +8,7 @@ import {
DialogTitle,
Divider,
IconButton,
- Stack,
SvgIcon,
- useMediaQuery,
} from "@mui/material";
import { Close } from "@mui/icons-material";
import { CippWizard } from "./CippWizard";
@@ -18,6 +16,7 @@ import { useRouter } from "next/router";
import { ArrowLeftIcon } from "@mui/x-date-pickers";
import { CippHead } from "../CippComponents/CippHead";
import { CippWizardDialogContext } from "./CippWizardDialogContext";
+import { useIsMobileLayout } from "../../hooks/use-breakpoint";
import { useState, useCallback } from "react";
const CippWizardPage = (props) => {
@@ -39,7 +38,7 @@ const CippWizardPage = (props) => {
...other
} = props;
- const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md"));
+ const isMobile = useIsMobileLayout();
const [actionsEl, setActionsEl] = useState(null);
const actionsRef = useCallback((el) => setActionsEl(el), []);
@@ -59,12 +58,12 @@ const CippWizardPage = (props) => {
onClose={onClose}
fullWidth
maxWidth="xl"
- fullScreen={mdDown}
+ fullScreen={isMobile}
PaperProps={{
sx: {
display: "flex",
flexDirection: "column",
- ...(!mdDown && { height: "90vh" }),
+ ...(!isMobile && { height: "90vh" }),
},
}}
>
@@ -76,7 +75,7 @@ const CippWizardPage = (props) => {
-
+
@@ -84,7 +83,7 @@ const CippWizardPage = (props) => {
-
+
);
}
@@ -96,21 +95,18 @@ const CippWizardPage = (props) => {
sx={{
backgroundColor: "background.default",
flexGrow: 1,
- pb: 4,
+ pb: { xs: 2, md: 4 },
}}
>
-
-
-
-
- {wizardNode}
-
-
-
-
+ {/* Three nested Stacks used to sit here, each wrapping exactly one child. Stack
+ spacing only emits a margin on :not(:first-of-type), so all three were inert
+ at every width. */}
+
+ {wizardNode}
+
>
diff --git a/src/components/CippWizard/CippWizardProgressHeader.jsx b/src/components/CippWizard/CippWizardProgressHeader.jsx
new file mode 100644
index 000000000000..bdb14abaf41a
--- /dev/null
+++ b/src/components/CippWizard/CippWizardProgressHeader.jsx
@@ -0,0 +1,45 @@
+import PropTypes from "prop-types";
+import { LinearProgress, Stack, Typography } from "@mui/material";
+
+/**
+ * The wizard's step indicator below md.
+ *
+ * A horizontal MUI Stepper gives every step a 36px icon beside two lines of text; with the
+ * 3-7 steps these wizards have, and ~326px of usable width on a phone, the labels collapse
+ * into each other. This says the same thing in the space available: where you are, what
+ * this step is, and how much is left.
+ *
+ * Takes the same two props as WizardSteps so the swap needs no new plumbing.
+ */
+export const CippWizardProgressHeader = (props) => {
+ const { activeStep = 0, steps = [] } = props;
+
+ const total = steps.length;
+ // Clamped because handleNext currently counts against the unfiltered step list, so
+ // activeStep can point past the end of a wizard whose steps are conditionally hidden.
+ const index = total > 0 ? Math.min(Math.max(activeStep, 0), total - 1) : 0;
+ const current = steps[index];
+ const value = total > 0 ? ((index + 1) / total) * 100 : 0;
+
+ return (
+
+
+ {total > 0 ? `Step ${index + 1} of ${total}` : "No steps"}
+
+ {current?.description ?? current?.title ?? ""}
+ {/* Carries the same error/loading states the step icons show on desktop, so the
+ GDAP-style "this step failed" signal survives the swap. */}
+
+
+ );
+};
+
+CippWizardProgressHeader.propTypes = {
+ activeStep: PropTypes.number,
+ steps: PropTypes.array,
+};
diff --git a/src/components/CippWizard/CippWizardStepButtons.jsx b/src/components/CippWizard/CippWizardStepButtons.jsx
index 7a070d124e08..55fb723d07b4 100644
--- a/src/components/CippWizard/CippWizardStepButtons.jsx
+++ b/src/components/CippWizard/CippWizardStepButtons.jsx
@@ -1,9 +1,10 @@
-import { Button, Stack } from "@mui/material";
+import { Button } from "@mui/material";
import { useFormState } from "react-hook-form";
import { createPortal } from "react-dom";
import { ApiPostCall } from "../../api/ApiCall";
import { CippApiResults } from "../CippComponents/CippApiResults";
import { useCippWizardDialog } from "./CippWizardDialogContext";
+import { CippWizardActionsRow } from "./CippWizardActionsRow";
export const CippWizardStepButtons = (props) => {
const {
@@ -47,20 +48,14 @@ export const CippWizardStepButtons = (props) => {
};
const buttonStack = (
-
+
{dialogContext?.onClose && (
Close
@@ -98,7 +93,7 @@ export const CippWizardStepButtons = (props) => {
{dialogContext.completionButton.label}
)}
-
+
);
return (
diff --git a/src/components/CippWizard/CippWizardVacationActions.jsx b/src/components/CippWizard/CippWizardVacationActions.jsx
index 7a8db5b558c7..cdf8130ea2bb 100644
--- a/src/components/CippWizard/CippWizardVacationActions.jsx
+++ b/src/components/CippWizard/CippWizardVacationActions.jsx
@@ -26,10 +26,15 @@ export const CippWizardVacationActions = (props) => {
const tenantDomain = currentTenant?.value || currentTenant
const enableCA = useWatch({ control: formControl.control, name: 'enableCAExclusion' })
+ const enableLocationAlertExclusion = useWatch({
+ control: formControl.control,
+ name: 'excludeLocationAuditAlerts',
+ })
const enableMailbox = useWatch({ control: formControl.control, name: 'enableMailboxPermissions' })
const enableForwarding = useWatch({ control: formControl.control, name: 'enableForwarding' })
const enableOOO = useWatch({ control: formControl.control, name: 'enableOOO' })
- const atLeastOneEnabled = enableCA || enableMailbox || enableForwarding || enableOOO
+ const atLeastOneEnabled =
+ enableCA || enableLocationAlertExclusion || enableMailbox || enableForwarding || enableOOO
const users = useWatch({ control: formControl.control, name: 'Users' })
const firstUser = Array.isArray(users) && users.length > 0 ? users[0] : null
@@ -194,14 +199,6 @@ export const CippWizardVacationActions = (props) => {
disabled={!tenantDomain}
/>
-
-
-
{
+ {/* Location Alert Exclusion Section */}
+
+
+
+
+
+
+
+
+
+ The users are added to the audit log location alert exclusion list at the start
+ date and removed again at the end date, so alerts that fire on sign-ins from an
+ unusual location stay quiet while they travel. This works on its own and does not
+ require a Conditional Access policy.
+
+
+
+
+
+
{/* Mailbox Permissions Section */}
{
const { formControl, onPreviousStep, currentStep, lastStep } = props
@@ -22,6 +23,7 @@ export const CippWizardVacationConfirmation = (props) => {
const values = useWatch({ control: formControl.control })
const caExclusion = ApiPostCall({ relatedQueryKeys: ['VacationMode'] })
+ const auditExclusion = ApiPostCall({ relatedQueryKeys: ['VacationMode'] })
const mailboxVacation = ApiPostCall({ relatedQueryKeys: ['VacationMode'] })
const forwardingVacation = ApiPostCall({ relatedQueryKeys: ['VacationMode'] })
const oooVacation = ApiPostCall({ relatedQueryKeys: ['VacationMode'] })
@@ -29,11 +31,13 @@ export const CippWizardVacationConfirmation = (props) => {
const tenantFilter = values.tenantFilter?.value || values.tenantFilter
const isSubmitting =
caExclusion.isPending ||
+ auditExclusion.isPending ||
mailboxVacation.isPending ||
forwardingVacation.isPending ||
oooVacation.isPending
const hasSubmitted =
caExclusion.isSuccess ||
+ auditExclusion.isSuccess ||
mailboxVacation.isSuccess ||
forwardingVacation.isSuccess ||
oooVacation.isSuccess
@@ -54,7 +58,6 @@ export const CippWizardVacationConfirmation = (props) => {
vacation: true,
reference: values.reference || null,
postExecution: values.postExecution || [],
- excludeLocationAuditAlerts: values.excludeLocationAuditAlerts || false,
// Only send the travel policy fields on the first request so the
// temporary policy is scheduled once, not once per selected CA policy
...(index === 0 && createTravelPolicy
@@ -68,6 +71,20 @@ export const CippWizardVacationConfirmation = (props) => {
})
}
+ if (values.excludeLocationAuditAlerts) {
+ auditExclusion.mutate({
+ url: '/api/ExecScheduleAuditExclusionVacation',
+ data: {
+ tenantFilter,
+ Users: values.Users,
+ startDate: values.startDate,
+ endDate: values.endDate,
+ reference: values.reference || null,
+ postExecution: values.postExecution || [],
+ },
+ })
+ }
+
if (values.enableMailboxPermissions) {
mailboxVacation.mutate({
url: '/api/ExecScheduleMailboxVacation',
@@ -225,6 +242,7 @@ export const CippWizardVacationConfirmation = (props) => {
{(() => {
const enabledCount = [
values.enableCAExclusion,
+ values.excludeLocationAuditAlerts,
values.enableMailboxPermissions,
values.enableForwarding,
values.enableOOO,
@@ -254,13 +272,6 @@ export const CippWizardVacationConfirmation = (props) => {
: 'Not selected'}
- {values.excludeLocationAuditAlerts && (
-
-
- Location-based audit log alerts will be excluded
-
-
- )}
{values.createTravelPolicy && (
@@ -284,6 +295,24 @@ export const CippWizardVacationConfirmation = (props) => {
)}
+ {values.excludeLocationAuditAlerts && (
+
+
+ }
+ />
+
+
+
+ The users are excluded from location-based audit log alerts between the start
+ and end date.
+
+
+
+
+ )}
+
{values.enableMailboxPermissions && (
@@ -434,18 +463,13 @@ export const CippWizardVacationConfirmation = (props) => {
{/* API Results */}
{values.enableCAExclusion && }
+ {values.excludeLocationAuditAlerts && }
{values.enableMailboxPermissions && }
{values.enableForwarding && }
{values.enableOOO && }
{/* Navigation + Custom Submit */}
-
+
{currentStep > 0 && (
Back
@@ -465,7 +489,7 @@ export const CippWizardVacationConfirmation = (props) => {
{isSubmitting ? 'Submitting...' : 'Submit'}
)}
-
+
)
}
diff --git a/src/components/CippWizard/wizard-steps.js b/src/components/CippWizard/wizard-steps.js
index 67b79a654105..bfdc473a7e51 100644
--- a/src/components/CippWizard/wizard-steps.js
+++ b/src/components/CippWizard/wizard-steps.js
@@ -1,5 +1,7 @@
import PropTypes from "prop-types";
import CheckIcon from "@heroicons/react/24/outline/CheckIcon";
+import { useIsMobileLayout } from "../../hooks/use-breakpoint";
+import { CippWizardProgressHeader } from "./CippWizardProgressHeader";
import {
Box,
Step,
@@ -137,6 +139,14 @@ const WizardStepIcon = (props) => {
export const WizardSteps = (props) => {
const { activeStep = 1, orientation = "vertical", steps = [] } = props;
+ const isMobile = useIsMobileLayout();
+
+ // Only the horizontal stepper is wizard navigation. The vertical one is a status list —
+ // GDAP onboarding feeds it server-side steps where each step's message and pass/fail
+ // state IS the content, so collapsing it to a progress bar would delete that.
+ if (isMobile && orientation === "horizontal") {
+ return ;
+ }
return (
@@ -145,8 +155,10 @@ export const WizardSteps = (props) => {
activeStep={activeStep}
connector={
}
>
- {steps.map((step) => (
-
+ {/* Onboarding's steps carry only a description, so keying on title alone made
+ every key undefined and reconciliation index-driven by accident. */}
+ {steps.map((step, index) => (
+
device.isEncrypted === true).length,
+ // Cloud PCs never report BitLocker but are platform-encrypted by Azure.
+ value: deviceData.filter(
+ (device) => device.isEncrypted === true || isCloudPcDevice(device),
+ ).length,
label: 'Encrypted',
},
]}
@@ -1622,6 +1628,10 @@ export const ExecutiveReportButton = (props) => {
setPreviewOpen(false)
}
+ // Below md the 320px config rail would leave the preview about 70px wide, so it moves into
+ // a drawer and the preview takes the whole dialog.
+ const [sectionsOpen, setSectionsOpen] = useState(false)
+
// Section configuration options
const sectionOptions = [
{
@@ -1671,6 +1681,102 @@ export const ExecutiveReportButton = (props) => {
},
]
+ // One definition, two homes: the desktop rail and the mobile drawer. The drawer's own
+ // header already says "Report Sections", so it takes the panel without the heading.
+ const sectionPanel = ({ showHeading = true } = {}) => (
+
+ {showHeading && (
+
+
+ Report Sections
+
+ )}
+
+ Configure which sections to include in your executive report. Changes are reflected in
+ real-time.
+
+
+
+ option.value === brandingPresetId) ?? presetOptions[0]
+ }
+ onChange={(option) => setPresetOverride(option?.value ?? '')}
+ />
+
+ Presets are managed in Settings → Branding
+
+
+
+
+ {sectionOptions.map((option) => (
+ handleSectionToggle(option.key)}
+ sx={{
+ p: 1.5,
+ border: '1px solid',
+ borderColor: sectionConfig[option.key] ? 'primary.main' : 'divider',
+ bgcolor: sectionConfig[option.key] ? 'primary.50' : 'background.paper',
+ cursor: 'pointer',
+ transition: 'all 0.2s ease-in-out',
+ display: 'flex',
+ alignItems: 'center',
+ '&:hover': {
+ borderColor: 'primary.main',
+ bgcolor: sectionConfig[option.key] ? 'primary.100' : 'primary.25',
+ },
+ }}
+ >
+ {
+ event.stopPropagation()
+ handleSectionToggle(option.key)
+ }}
+ onClick={(event) => event.stopPropagation()}
+ color="primary"
+ size="small"
+ disabled={
+ sectionConfig[option.key] &&
+ Object.values(sectionConfig).filter(Boolean).length === 1
+ }
+ />
+
+
+ {option.label}
+
+
+ {option.description}
+
+
+
+ ))}
+
+
+
+
+ 💡 Pro Tip
+
+
+ Enable only the sections relevant to your audience to create focused, impactful reports.
+ At least one section must be enabled.
+
+
+
+ )
+
return (
<>
{/* Main Executive Summary Button - Always available */}
@@ -1742,8 +1848,9 @@ export const ExecutiveReportButton = (props) => {
fullWidth
sx={{
'& .MuiDialog-paper': {
- height: '95vh',
- maxHeight: '95vh',
+ // dvh, not vh: iOS counts the collapsing address bar in vh, so 95vh overflows.
+ height: { xs: '100dvh', md: '95vh' },
+ maxHeight: { xs: '100dvh', md: '95vh' },
},
}}
>
@@ -1757,16 +1864,28 @@ export const ExecutiveReportButton = (props) => {
borderColor: 'divider',
}}
>
-
+
Executive Report - {tenantName}
-
-
-
+
+ {/* The config rail's stand-in below md, in the title bar because the dialog is
+ full-screen there and this is the only chrome that stays put. */}
+ setSectionsOpen(true)}
+ size="small"
+ aria-label="Report sections"
+ sx={{ display: { xs: 'inline-flex', md: 'none' } }}
+ >
+
+
+
+
+
+
- {/* Left Panel - Section Configuration */}
+ {/* Left Panel - Section Configuration. Below md it lives in the drawer instead. */}
{
borderColor: 'divider',
height: '100%',
overflow: 'auto',
+ display: { xs: 'none', md: 'block' },
}}
>
-
-
-
- Report Sections
-
-
- Configure which sections to include in your executive report. Changes are reflected
- in real-time.
-
-
-
- option.value === brandingPresetId) ??
- presetOptions[0]
- }
- onChange={(option) => setPresetOverride(option?.value ?? '')}
- />
-
- Presets are managed in Settings → Branding
-
-
-
-
- {sectionOptions.map((option) => (
- handleSectionToggle(option.key)}
- sx={{
- p: 1.5,
- border: '1px solid',
- borderColor: sectionConfig[option.key] ? 'primary.main' : 'divider',
- bgcolor: sectionConfig[option.key] ? 'primary.50' : 'background.paper',
- cursor: 'pointer',
- transition: 'all 0.2s ease-in-out',
- display: 'flex',
- alignItems: 'center',
- '&:hover': {
- borderColor: 'primary.main',
- bgcolor: sectionConfig[option.key] ? 'primary.100' : 'primary.25',
- },
- }}
- >
- {
- event.stopPropagation()
- handleSectionToggle(option.key)
- }}
- onClick={(event) => event.stopPropagation()}
- color="primary"
- size="small"
- disabled={
- sectionConfig[option.key] &&
- Object.values(sectionConfig).filter(Boolean).length === 1
- }
- />
-
-
- {option.label}
-
-
- {option.description}
-
-
-
- ))}
-
-
-
-
- 💡 Pro Tip
-
-
- Enable only the sections relevant to your audience to create focused, impactful
- reports. At least one section must be enabled.
-
-
-
+ {sectionPanel()}
{/* Right Panel - PDF Preview */}
-
+
{isDataLoading ? (
{
justifyContent: 'center',
height: '100%',
gap: 2,
+ // Gutters and a measure: this pane is the full width of the screen below md,
+ // where the second line is long enough to run edge to edge and break badly.
+ px: 3,
+ textAlign: 'center',
}}
>
Loading Report Data...
-
+
Fetching additional data for comprehensive report generation
) : reportDocument ? (
- {
showToolbar={true}
>
{reportDocument}
-
+
) : (
{
-
+ :not(style) ~ :not(style)': { ml: { xs: 0, md: 1 } },
+ }}
+ >
Sections enabled: {Object.values(sectionConfig).filter(Boolean).length} of{' '}
@@ -2000,6 +2036,19 @@ export const ExecutiveReportButton = (props) => {
Close
+
+ {/* Mounted inside the Dialog so it inherits its theme scope; aboveModal lifts it over
+ the dialog it is opened from. */}
+ setSectionsOpen(false)}
+ title="Report Sections"
+ size="sm"
+ contentPadding={0}
+ aboveModal
+ >
+ {sectionPanel({ showHeading: false })}
+
>
)
diff --git a/src/components/ReleaseNotesDialog.js b/src/components/ReleaseNotesDialog.js
index dbdf649ed410..08811b2286f6 100644
--- a/src/components/ReleaseNotesDialog.js
+++ b/src/components/ReleaseNotesDialog.js
@@ -10,6 +10,12 @@
} from 'react'
import {
Box,
+ ButtonBase,
+ IconButton,
+ List,
+ ListItemButton,
+ ListItemIcon,
+ ListItemText,
Button,
CircularProgress,
Dialog,
@@ -20,14 +26,18 @@ import {
Stack,
Typography,
} from '@mui/material'
+import { visuallyHidden } from '@mui/utils'
import ReactMarkdown from 'react-markdown'
+import { useHistoryDismiss } from '../hooks/use-history-dismiss'
+import { CippBottomSheet } from './CippComponents/CippBottomSheet'
+import { useIsMobileLayout } from '../hooks/use-breakpoint'
import remarkGfm from 'remark-gfm'
import remarkParse from 'remark-parse'
import rehypeRaw from 'rehype-raw'
import { unified } from 'unified'
import packageInfo from '../../public/version.json'
import { ApiGetCall } from '../api/ApiCall'
-import { GitHub } from '@mui/icons-material'
+import { Check, Close, GitHub, KeyboardArrowDown, MoreHoriz } from '@mui/icons-material'
import { CippAutoComplete } from './CippComponents/CippAutocomplete'
const RELEASE_COOKIE_KEY = 'cipp_release_notice'
@@ -79,16 +89,22 @@ const deleteCookie = (name) => {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax;${secureFlag()}`
}
+// Hotfix and maintenance builds publish their own GitHub release (v10.8.1, v10.8.2, ...), so the
+// running build's exact tag is both what we show and what we remember as dismissed. Collapsing
+// patch releases back to vX.Y.0 here left the dismissal cookie - which stores the tag that was
+// actually released - permanently unmatchable, so the dialog reopened on every page load.
+// baseTag (vX.Y.0) is what the dialog selects by default so the feature-release notes lead;
+// hotfix notes stay reachable via the dropdown.
const buildReleaseMetadata = (version) => {
- const [major = '0', minor = '0', patch = '0'] = String(version).split('.')
+ const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(version ?? ''))
+ const [major, minor, patch] = match ? match.slice(1) : ['0', '0', '0']
const currentTag = `v${major}.${minor}.${patch}`
- const baseTag = `v${major}.${minor}.0`
- const tagToUse = patch === '0' ? currentTag : baseTag
return {
currentTag,
- releaseTag: tagToUse,
- releaseUrl: `https://github.com/${RELEASE_OWNER}/${RELEASE_REPO}/releases/tag/${tagToUse}`,
+ baseTag: `v${major}.${minor}.0`,
+ releaseTag: currentTag,
+ releaseUrl: `https://github.com/${RELEASE_OWNER}/${RELEASE_REPO}/releases/tag/${currentTag}`,
}
}
@@ -128,21 +144,40 @@ class MarkdownErrorBoundary extends Component {
}
}
+// Which release the dialog *shows*. Hotfix and maintenance builds (v10.8.1, v10.8.2) carry
+// only the delta since the feature release, so opening on one tells the user almost nothing
+// about what changed. Default to the newest vX.Y.0 instead; the picker still lists every
+// release, and dismissal keeps tracking the exact running tag (see buildReleaseMetadata) so
+// this can't reintroduce the dialog-reopens-forever bug.
+const isFeatureRelease = (tag) => /^v?\d+\.\d+\.0$/.test(String(tag ?? ''))
+
+const pickDisplayRelease = (catalog, releaseMeta) =>
+ catalog.find((release) => isFeatureRelease(release.releaseTag)) ||
+ catalog.find((release) => release.releaseTag === releaseMeta.releaseTag) ||
+ catalog.find((release) => release.releaseTag === releaseMeta.baseTag) ||
+ catalog[0]
+
export const ReleaseNotesDialog = forwardRef((_props, ref) => {
const releaseMeta = useMemo(() => buildReleaseMetadata(packageInfo.version), [])
const [isEligible, setIsEligible] = useState(false)
const [open, setOpen] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
const [manualOpenRequested, setManualOpenRequested] = useState(false)
- const [selectedReleaseTag, setSelectedReleaseTag] = useState(releaseMeta.releaseTag)
+ const [moreActionsOpen, setMoreActionsOpen] = useState(false)
+ const [releasePickerOpen, setReleasePickerOpen] = useState(false)
+ // Left unset until the catalog loads so pickDisplayRelease chooses; seeding it with
+ // the running tag meant a hotfix build always displayed its own thin release notes.
+ const [selectedReleaseTag, setSelectedReleaseTag] = useState(null)
const hasOpenedRef = useRef(false)
+ const isMobile = useIsMobileLayout()
useEffect(() => {
hasOpenedRef.current = false
}, [releaseMeta.releaseTag])
useEffect(() => {
- setSelectedReleaseTag(releaseMeta.releaseTag)
+ // New build -> re-pick from the catalog rather than pinning to this build's tag
+ setSelectedReleaseTag(null)
}, [releaseMeta.releaseTag])
useEffect(() => {
@@ -185,26 +220,30 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => {
}
if (!selectedReleaseTag) {
- setSelectedReleaseTag(releaseCatalog[0].releaseTag)
+ setSelectedReleaseTag(pickDisplayRelease(releaseCatalog, releaseMeta)?.releaseTag)
return
}
const hasSelected = releaseCatalog.some((release) => release.releaseTag === selectedReleaseTag)
if (!hasSelected) {
- const fallbackRelease =
- releaseCatalog.find((release) => release.releaseTag === releaseMeta.releaseTag) ||
- releaseCatalog[0]
+ const fallbackRelease = pickDisplayRelease(releaseCatalog, releaseMeta)
if (fallbackRelease) {
setSelectedReleaseTag(fallbackRelease.releaseTag)
}
}
- }, [releaseCatalog, selectedReleaseTag, releaseMeta.releaseTag])
+ }, [releaseCatalog, selectedReleaseTag, releaseMeta])
const releaseOptions = useMemo(() => {
const mapped = releaseCatalog.map((release) => {
const tag = release.releaseTag ?? release.tagName
- const label = release.name ? `${release.name} (${tag})` : tag
+ // GitHub release names usually start with the tag ("v10.8.0 - Ramos Melon Fizz"),
+ // so the parenthetical only earns its width when the name doesn't carry it.
+ const label = release.name
+ ? release.name.includes(tag)
+ ? release.name
+ : `${release.name} (${tag})`
+ : tag
return {
label,
value: tag,
@@ -267,15 +306,17 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => {
return (
releaseCatalog.find((release) => release.releaseTag === selectedReleaseTag) ||
releaseCatalog.find((release) => release.releaseTag === releaseMeta.releaseTag) ||
+ releaseCatalog.find((release) => release.releaseTag === releaseMeta.baseTag) ||
null
)
- }, [releaseCatalog, selectedReleaseTag, releaseMeta.releaseTag])
+ }, [releaseCatalog, selectedReleaseTag, releaseMeta])
const handleDismissUntilNextRelease = () => {
- const newestRelease = releaseCatalog[0]
- const tagToStore = newestRelease?.releaseTag ?? newestRelease?.tagName ?? releaseMeta.releaseTag
+ // Store the same tag the eligibility check reads back - the tag of the build being run, not
+ // the newest tag on GitHub. Those differ for anyone not on the very latest release, and a
+ // cookie that can never match means "don't show until next release" never suppresses anything.
window.localStorage.removeItem(RELEASE_PERMANENT_HIDE_KEY)
- setCookie(RELEASE_COOKIE_KEY, tagToStore)
+ setCookie(RELEASE_COOKIE_KEY, releaseMeta.releaseTag)
setOpen(false)
setIsExpanded(false)
setManualOpenRequested(false)
@@ -298,6 +339,10 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => {
setManualOpenRequested(false)
}
+ // Phone back gesture dismisses the dialog instead of navigating the page away — same
+ // remind-later semantics as the ✕, the backdrop and Esc.
+ useHistoryDismiss(open, handleRemindLater, isMobile)
+
const toggleExpanded = () => {
setIsExpanded((prev) => !prev)
}
@@ -358,33 +403,72 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => {
},
}}
>
-
-
+ {isMobile ? (
+ setReleasePickerOpen(true)}
+ aria-haspopup="dialog"
+ sx={{
+ minWidth: 0,
+ flex: 1,
+ display: 'flex',
+ alignItems: 'center',
+ gap: 0.5,
+ borderRadius: 1,
+ textAlign: 'left',
+ justifyContent: 'flex-start',
+ }}
+ >
+
+ {selectedReleaseValue?.label ?? 'Release notes'}
+
+
+ switch release
+
+
+
+ ) : (
+
+
+ {`Release notes for ${releaseHeading}`}
+
+
+
+ {isExpanded ? 'Shrink' : 'Expand'}
+
+
+ )}
+ {/* Phones drop the "Remind me next time" button — closing IS remind-later
+ (onClose runs the same handler) — so the ✕ is the visible way to do it. */}
+
-
- {`Release notes for ${releaseHeading}`}
-
-
-
- {isExpanded ? 'Shrink' : 'Expand'}
-
-
+
+
@@ -414,8 +498,25 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => {
{
- }
- >
- View release notes on GitHub
-
-
+ }
+ sx={{ mr: { md: 'auto' } }}
+ >
+ View release notes on GitHub
+
{
>
Don't show again
-
- Remind me next time
-
-
+
+
+ Remind me next time
+
+
+
Don't show until next release
-
+ setMoreActionsOpen(true)}
+ sx={{
+ display: { xs: 'inline-flex', md: 'none' },
+ minWidth: 44,
+ minHeight: 44,
+ border: 1,
+ borderColor: 'divider',
+ borderRadius: 1,
+ }}
+ >
+
+
+
+ setReleasePickerOpen(false)}
+ title="Release"
+ >
+
+ {releaseOptions.map((option) => {
+ const selected = option.value === selectedReleaseTag
+ return (
+ {
+ setReleasePickerOpen(false)
+ if (!selected) handleReleaseChange(option)
+ }}
+ >
+
+ {selected && }
+
+ )
+ })}
+
+
+ setMoreActionsOpen(false)}
+ title="Release notes"
+ >
+
+ setMoreActionsOpen(false)}
+ sx={{ minHeight: 48 }}
+ >
+
+
+
+
+
+ {
+ setMoreActionsOpen(false)
+ handleDismissPermanently()
+ }}
+ sx={{ minHeight: 48 }}
+ >
+
+
+
+
+
+
+
)
})
diff --git a/src/components/ReportBuilder/ReportBuilderPDF.js b/src/components/ReportBuilder/ReportBuilderPDF.js
index 8ed9160b9867..d0f0a85235e2 100644
--- a/src/components/ReportBuilder/ReportBuilderPDF.js
+++ b/src/components/ReportBuilder/ReportBuilderPDF.js
@@ -1,5 +1,6 @@
import { useMemo } from 'react'
-import { Text, View, StyleSheet, PDFViewer } from '@react-pdf/renderer'
+import { Text, View, StyleSheet } from '@react-pdf/renderer'
+import { CippPdfPreview } from '../CippPdf/CippPdfPreview'
import {
ContentPage,
DEFAULT_PAGE_SETUP,
@@ -650,9 +651,15 @@ export const ReportBuilderPDF = ({
if (mode === 'preview') {
return (
-
+
{document}
-
+
)
}
return null
diff --git a/src/components/ShadowAIReportButton.js b/src/components/ShadowAIReportButton.js
index 54153c42a0bc..82b13955ca47 100644
--- a/src/components/ShadowAIReportButton.js
+++ b/src/components/ShadowAIReportButton.js
@@ -15,7 +15,8 @@ import {
Typography,
} from '@mui/material'
import { Close, Download, PictureAsPdf, Settings } from '@mui/icons-material'
-import { PDFViewer } from '@react-pdf/renderer'
+import { CippPdfPreview } from './CippPdf/CippPdfPreview'
+import { CippOffCanvas } from './CippComponents/CippOffCanvas'
import { useReportVariables } from './CippPdf/useReportVariables'
import { useBrandingSettings } from './CippPdf/useBrandingSettings'
import {
@@ -576,6 +577,9 @@ export const ShadowAIReportButton = ({ data, tenantName, disabled }) => {
const brandingSettings = useBrandingSettings()
const variables = useReportVariables()
const [previewOpen, setPreviewOpen] = useState(false)
+ // Below md the 320px config rail would leave the preview about 70px wide, so it moves into
+ // a drawer and the preview takes the whole dialog. Same treatment as the executive report.
+ const [sectionsOpen, setSectionsOpen] = useState(false)
const [sectionConfig, setSectionConfig] = useState({
coverPage: true,
executiveSummary: true,
@@ -603,6 +607,73 @@ export const ShadowAIReportButton = ({ data, tenantName, disabled }) => {
new Date().toISOString().split('T')[0]
}.pdf`
+ // One definition, two homes: the desktop rail and the mobile drawer. The drawer's own
+ // header already says "Report Sections", so it takes the panel without the heading.
+ const sectionPanel = ({ showHeading = true } = {}) => (
+
+ {showHeading && (
+
+
+ Report Sections
+
+ )}
+
+ Configure which sections to include in your Shadow AI report. Changes are reflected in
+ real-time.
+
+
+
+ {sectionOptions.map((option) => (
+ handleSectionToggle(option.key)}
+ sx={{
+ p: 1.5,
+ border: '1px solid',
+ borderColor: sectionConfig[option.key] ? 'primary.main' : 'divider',
+ bgcolor: sectionConfig[option.key] ? 'primary.50' : 'background.paper',
+ cursor: 'pointer',
+ transition: 'all 0.2s ease-in-out',
+ display: 'flex',
+ alignItems: 'center',
+ '&:hover': {
+ borderColor: 'primary.main',
+ bgcolor: sectionConfig[option.key] ? 'primary.100' : 'primary.25',
+ },
+ }}
+ >
+ {
+ event.stopPropagation()
+ handleSectionToggle(option.key)
+ }}
+ onClick={(event) => event.stopPropagation()}
+ color="primary"
+ size="small"
+ disabled={
+ sectionConfig[option.key] &&
+ Object.values(sectionConfig).filter(Boolean).length === 1
+ }
+ />
+
+
+ {option.label}
+
+
+ {option.description}
+
+
+
+ ))}
+
+
+ )
+
const reportDocument = useMemo(() => {
if (!previewOpen) return null
return (
@@ -642,7 +713,13 @@ export const ShadowAIReportButton = ({ data, tenantName, disabled }) => {
onClose={() => setPreviewOpen(false)}
maxWidth="xl"
fullWidth
- sx={{ '& .MuiDialog-paper': { height: '95vh', maxHeight: '95vh' } }}
+ sx={{
+ '& .MuiDialog-paper': {
+ // dvh, not vh: iOS counts the collapsing address bar in vh, so 95vh overflows.
+ height: { xs: '100dvh', md: '95vh' },
+ maxHeight: { xs: '100dvh', md: '95vh' },
+ },
+ }}
>
{
borderColor: 'divider',
}}
>
-
+
Shadow AI Report - {tenantName}
- setPreviewOpen(false)} size="small">
-
-
+
+ {/* The config rail's stand-in below md, in the title bar because the dialog is
+ full-screen there and this is the only chrome that stays put. */}
+ setSectionsOpen(true)}
+ size="small"
+ aria-label="Report sections"
+ sx={{ display: { xs: 'inline-flex', md: 'none' } }}
+ >
+
+
+ setPreviewOpen(false)}
+ size="small"
+ aria-label="Close preview"
+ >
+
+
+
- {/* Left Panel - Section Configuration */}
+ {/* Left Panel - Section Configuration. Below md it lives in the drawer instead. */}
{
borderColor: 'divider',
height: '100%',
overflow: 'auto',
+ display: { xs: 'none', md: 'block' },
}}
>
-
-
-
- Report Sections
-
-
- Configure which sections to include in your Shadow AI report. Changes are reflected
- in real-time.
-
-
-
- {sectionOptions.map((option) => (
- handleSectionToggle(option.key)}
- sx={{
- p: 1.5,
- border: '1px solid',
- borderColor: sectionConfig[option.key] ? 'primary.main' : 'divider',
- bgcolor: sectionConfig[option.key] ? 'primary.50' : 'background.paper',
- cursor: 'pointer',
- transition: 'all 0.2s ease-in-out',
- display: 'flex',
- alignItems: 'center',
- '&:hover': {
- borderColor: 'primary.main',
- bgcolor: sectionConfig[option.key] ? 'primary.100' : 'primary.25',
- },
- }}
- >
- {
- event.stopPropagation()
- handleSectionToggle(option.key)
- }}
- onClick={(event) => event.stopPropagation()}
- color="primary"
- size="small"
- disabled={
- sectionConfig[option.key] &&
- Object.values(sectionConfig).filter(Boolean).length === 1
- }
- />
-
-
- {option.label}
-
-
- {option.description}
-
-
-
- ))}
-
-
+ {sectionPanel()}
{/* Right Panel - PDF Preview */}
-
+
{reportDocument && (
-
{reportDocument}
-
+
)}
-
+ :not(style) ~ :not(style)': { ml: { xs: 0, md: 1 } },
+ }}
+ >
Sections enabled: {Object.values(sectionConfig).filter(Boolean).length} of{' '}
@@ -803,6 +844,19 @@ export const ShadowAIReportButton = ({ data, tenantName, disabled }) => {
Close
+
+ {/* Mounted inside the Dialog so it inherits its theme scope; aboveModal lifts it over
+ the dialog it is opened from. */}
+ setSectionsOpen(false)}
+ title="Report Sections"
+ size="sm"
+ contentPadding={0}
+ aboveModal
+ >
+ {sectionPanel({ showHeading: false })}
+
>
)
diff --git a/src/components/actions-menu.js b/src/components/actions-menu.js
index 77a4c1c6a6cc..19f4ec9f2d66 100644
--- a/src/components/actions-menu.js
+++ b/src/components/actions-menu.js
@@ -2,25 +2,17 @@ import ChevronDownIcon from "@heroicons/react/24/outline/ChevronDownIcon";
import PropTypes from "prop-types";
import { Button, ListItemText, Menu, MenuItem, SvgIcon } from "@mui/material";
import { usePopover } from "../hooks/use-popover";
-import { useState } from "react";
-import { useDialog } from "../hooks/use-dialog";
-import { CippApiDialog } from "./CippComponents/CippApiDialog";
+import { useActionsDispatch } from "../hooks/use-actions-dispatch";
export const ActionsMenu = (props) => {
const { actions = [], label = "Actions", data, queryKeys, ...other } = props;
const popover = usePopover();
- const [actionData, setActionData] = useState({ data: {}, action: {}, ready: false });
- const createDialog = useDialog();
- const handleActionDisabled = (row, action) => {
- //add nullsaftey for row. It can sometimes be undefined(still loading) or null(no data)
- if (!row) {
- return true;
- }
- if (action?.condition) {
- return !action?.condition(row);
- }
- return false;
- };
+ const { visibleActions, isDisabled, dispatch, dialog } = useActionsDispatch({
+ actions,
+ data,
+ queryKeys,
+ });
+
return (
<>
{
whiteSpace: "nowrap",
}}
>
- Actions
+ {label}
{
vertical: "top",
}}
>
- {actions
- ?.filter((action) => !action.link || action.showInActionsMenu)
- .map((action, index) => (
- {
- setActionData({
- data: data,
- action: action,
- ready: true,
- });
-
- if (action?.noConfirm && action.customFunction) {
- action.customFunction(data, action, {});
- popover.handleClose();
- } else {
- createDialog.handleOpen();
- popover.handleClose();
- }
- }}
- >
-
- {action.icon}
-
- {action.label}
-
- ))}
+ {visibleActions.map((action, index) => (
+ {
+ dispatch(action);
+ popover.handleClose();
+ }}
+ >
+
+ {action.icon}
+
+ {action.label}
+
+ ))}
- {actionData.ready && (
-
- )}
+ {dialog}
>
);
};
diff --git a/src/components/images-dialog.js b/src/components/images-dialog.js
index 71668979d5db..d17c6d1cad8f 100644
--- a/src/components/images-dialog.js
+++ b/src/components/images-dialog.js
@@ -92,7 +92,7 @@ export const ImagesDialog = (props) => {
onDrop={handleDrop}
sx={{ mb: 3 }}
/>
- {
};
/**
- * Branding is no longer client settings — it is a request, cached by react-query under
- * `BRANDING_QUERY_KEY` and read via `useBrandingSettings`. Anything a previous version of CIPP
- * persisted here is dropped on load rather than migrated: it is a stale copy of server state, and
- * its image payloads are what used to blow the localStorage quota once covers were uploaded.
+ * Branding is server state now, read via `useBrandingSettings`. Anything a previous version
+ * persisted here is dropped on load rather than migrated - it is a stale copy, and its image
+ * payloads used to exhaust the localStorage quota.
*/
const stripPersistedBrandingBlobs = (settings) => {
if (!settings || typeof settings !== "object" || !("customBranding" in settings)) {
@@ -122,6 +121,9 @@ const initialSettings = {
bookmarkSidebar: true,
bookmarkPopover: false,
compactNav: false,
+ // 'auto' = card list below the md breakpoint, classic table at or above it.
+ // 'cards' / 'table' force one presentation everywhere (per-device escape hatch).
+ tableViewMode: "auto",
};
const initialState = {
diff --git a/src/data/CIPPDBCacheTypes.json b/src/data/CIPPDBCacheTypes.json
index 9eac150e0215..0b5b85e60bb5 100644
--- a/src/data/CIPPDBCacheTypes.json
+++ b/src/data/CIPPDBCacheTypes.json
@@ -332,7 +332,7 @@
{
"type": "ManagedDeviceEncryptionStates",
"friendlyName": "Managed Device Encryption States",
- "description": "BitLocker encryption states for managed devices"
+ "description": "BitLocker encryption states for managed devices; Windows 365 Cloud PCs are marked encryptedByPlatform"
},
{
"type": "IntuneAppProtectionPolicies",
@@ -353,5 +353,10 @@
"type": "IntuneAppInstallStatus",
"friendlyName": "Intune App Install Status",
"description": "Per-application install status rollup (failed/installed/pending device counts) from the AppInstallStatusAggregate report"
+ },
+ {
+ "type": "DomainAnalyser",
+ "friendlyName": "Domain Analyser",
+ "description": "Domain Analyser results per domain: SPF, MX, DMARC, DKIM, DNSSEC, enrollment CNAMEs and the domain health score"
}
]
diff --git a/src/data/Extensions.json b/src/data/Extensions.json
index 8643c7b44b22..660661bf91e5 100644
--- a/src/data/Extensions.json
+++ b/src/data/Extensions.json
@@ -374,6 +374,32 @@
"action": "disable"
}
},
+ {
+ "type": "autoComplete",
+ "name": "HaloPSA.RequestSource",
+ "label": "HaloPSA Request Source",
+ "placeholder": "Select a request source, leave blank for default",
+ "helperText": "Optional. Stamps every CIPP-generated ticket with this request source. Halo records tickets created over the API as Manual unless one is set, so create a CIPP request source in Halo to tell them apart.",
+ "fullRow": true,
+ "multiple": false,
+ "api": {
+ "url": "/api/ExecExtensionMapping",
+ "data": {
+ "List": "HaloPSARequestSources"
+ },
+ "queryKey": "HaloRequestSources",
+ "dataKey": "RequestSources",
+ "labelField": "name",
+ "valueField": "id",
+ "showRefresh": true
+ },
+ "condition": {
+ "field": "HaloPSA.Enabled",
+ "compareType": "is",
+ "compareValue": true,
+ "action": "disable"
+ }
+ },
{
"type": "autoComplete",
"name": "HaloPSA.DefaultPriority",
diff --git a/src/data/M365Licenses.json b/src/data/M365Licenses.json
index a5075fa9e6ef..12bf255142de 100644
--- a/src/data/M365Licenses.json
+++ b/src/data/M365Licenses.json
@@ -32047,6 +32047,54 @@
"Service_Plan_Id": "78b58230-ec7e-4309-913c-93a45cc4735b",
"Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Webinar"
},
+ {
+ "Product_Display_Name": "Microsoft Teams Premium",
+ "String_Id": "M365_TEAMS_PREMIUM",
+ "GUID": "6432c818-bcef-43b6-9290-aec052964950",
+ "Service_Plan_Name": "TEAMSPRO_MGMT",
+ "Service_Plan_Id": "0504111f-feb8-4a3c-992a-70280f9a2869",
+ "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Intelligent"
+ },
+ {
+ "Product_Display_Name": "Microsoft Teams Premium",
+ "String_Id": "M365_TEAMS_PREMIUM",
+ "GUID": "6432c818-bcef-43b6-9290-aec052964950",
+ "Service_Plan_Name": "TEAMSPRO_CUST",
+ "Service_Plan_Id": "cc8c0802-a325-43df-8cba-995d0c6cb373",
+ "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Personalized"
+ },
+ {
+ "Product_Display_Name": "Microsoft Teams Premium",
+ "String_Id": "M365_TEAMS_PREMIUM",
+ "GUID": "6432c818-bcef-43b6-9290-aec052964950",
+ "Service_Plan_Name": "TEAMSPRO_PROTECTION",
+ "Service_Plan_Id": "f8b44f54-18bb-46a3-9658-44ab58712968",
+ "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Secure"
+ },
+ {
+ "Product_Display_Name": "Microsoft Teams Premium",
+ "String_Id": "M365_TEAMS_PREMIUM",
+ "GUID": "6432c818-bcef-43b6-9290-aec052964950",
+ "Service_Plan_Name": "TEAMSPRO_VIRTUALAPPT",
+ "Service_Plan_Id": "9104f592-f2a7-4f77-904c-ca5a5715883f",
+ "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Virtual Appointment"
+ },
+ {
+ "Product_Display_Name": "Microsoft Teams Premium",
+ "String_Id": "M365_TEAMS_PREMIUM",
+ "GUID": "6432c818-bcef-43b6-9290-aec052964950",
+ "Service_Plan_Name": "MCO_VIRTUAL_APPT",
+ "Service_Plan_Id": "711413d0-b36e-4cd4-93db-0a50a4ab7ea3",
+ "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Virtual Appointments"
+ },
+ {
+ "Product_Display_Name": "Microsoft Teams Premium",
+ "String_Id": "M365_TEAMS_PREMIUM",
+ "GUID": "6432c818-bcef-43b6-9290-aec052964950",
+ "Service_Plan_Name": "QUEUES_APP",
+ "Service_Plan_Id": "ab2d4fb5-f80a-4bf1-a11d-7f1da254041b",
+ "Service_Plans_Included_Friendly_Names": "Queues app for Microsoft Teams"
+ },
{
"Product_Display_Name": "Microsoft Teams Rooms Basic",
"String_Id": "Microsoft_Teams_Rooms_Basic",
@@ -32215,6 +32263,14 @@
"Service_Plan_Id": "4a51bca5-1eff-43f5-878c-177680f191af",
"Service_Plans_Included_Friendly_Names": "Whiteboard (Plan 3)"
},
+ {
+ "Product_Display_Name": "Microsoft Teams Rooms Pro",
+ "String_Id": "Microsoft_Teams_Rooms_Pro",
+ "GUID": "4cde982a-ede4-4409-9ae6-b003453c8ea6",
+ "Service_Plan_Name": "MICROSOFT_TEAMS_EVENTS",
+ "Service_Plan_Id": "29c62f1c-8ffc-4304-9cb9-398a6aa1852b",
+ "Service_Plans_Included_Friendly_Names": "Microsoft Teams Events"
+ },
{
"Product_Display_Name": "Microsoft Teams Rooms Pro for EDU",
"String_Id": "Microsoft_Teams_Rooms_Pro_FAC",
@@ -33031,6 +33087,14 @@
"Service_Plan_Id": "b622badb-1b45-48d5-920f-4b27a2c0996c",
"Service_Plans_Included_Friendly_Names": "Microsoft Workplace Analytics Insights User"
},
+ {
+ "Product_Display_Name": "Microsoft Workplace Analytics",
+ "String_Id": "WORKPLACE_ANALYTICS",
+ "GUID": "3d957427-ecdc-4df2-aacd-01cc9d519da8",
+ "Service_Plan_Name": "SKILLS_IN_VIVA",
+ "Service_Plan_Id": "ccaebebf-3634-4975-a0ad-3eccb697f393",
+ "Service_Plans_Included_Friendly_Names": "Skills in Viva"
+ },
{
"Product_Display_Name": "Minecraft Education Faculty",
"String_Id": "MEE_FACULTY",
@@ -38287,6 +38351,14 @@
"Service_Plan_Id": "65cc641f-cccd-4643-97e0-a17e3045e541",
"Service_Plans_Included_Friendly_Names": "Microsoft Records Management"
},
+ {
+ "Product_Display_Name": "Office 365 E5",
+ "String_Id": "ENTERPRISEPREMIUM",
+ "GUID": "c7df2760-2c81-4ef7-b578-5b5392b571df",
+ "Service_Plan_Name": "MICROSOFT_TEAMS_EVENTS",
+ "Service_Plan_Id": "29c62f1c-8ffc-4304-9cb9-398a6aa1852b",
+ "Service_Plans_Included_Friendly_Names": "Microsoft Teams Events"
+ },
{
"Product_Display_Name": "Office 365 E5 EEA (no Teams)",
"String_Id": "Office_365_w/o_Teams_Bundle_E5",
@@ -45319,6 +45391,14 @@
"Service_Plan_Id": "5a10155d-f5c1-411a-a8ec-e99aae125390",
"Service_Plans_Included_Friendly_Names": "DOMESTIC AND INTERNATIONAL CALLING PLAN"
},
+ {
+ "Product_Display_Name": "Skype for Business PSTN Domestic and International Calling",
+ "String_Id": "MCOPSTN2",
+ "GUID": "d3b4fe1f-9992-4930-8acb-ca6ec609365e",
+ "Service_Plan_Name": "MCOSMS2",
+ "Service_Plan_Id": "d4009785-b899-4cab-97b6-d06a7c799507",
+ "Service_Plans_Included_Friendly_Names": "DOMESTIC AND INTERNATIONAL CALLING PLAN"
+ },
{
"Product_Display_Name": "Skype for Business PSTN Domestic Calling",
"String_Id": "MCOPSTN1",
@@ -46703,6 +46783,14 @@
"Service_Plan_Id": "113feb6c-3fe4-4440-bddc-54d774bf0318",
"Service_Plans_Included_Friendly_Names": "Exchange Foundation"
},
+ {
+ "Product_Display_Name": "Windows 365 Enterprise 4 vCPU 16 GB 128 GB",
+ "String_Id": "CPC_E_4C_16GB_128GB",
+ "GUID": "d201f153-d3b2-4057-be2f-fe25c8983e6f",
+ "Service_Plan_Name": "Windows_10_ESU_Commercial",
+ "Service_Plan_Id": "6dc0e3c6-2e4e-463c-90a4-9989d8543841",
+ "Service_Plans_Included_Friendly_Names": "Windows 10 ESU Commercial"
+ },
{
"Product_Display_Name": "Windows 365 Enterprise 4 vCPU 16 GB 128 GB",
"String_Id": "CPC_E_4C_16GB_128GB",
@@ -46847,6 +46935,14 @@
"Service_Plan_Id": "50ef7026-6174-40ba-bff7-f0e4fcddbf65",
"Service_Plans_Included_Friendly_Names": "Windows 365 Shared Use 2 vCPU 8 GB 256 GB"
},
+ {
+ "Product_Display_Name": "Windows 365 Shared Use 4 vCPU 16 GB 128 GB",
+ "String_Id": "Windows_365_S_4vCPU_16GB_128GB",
+ "GUID": "1bf40e76-4065-4530-ac37-f1513f362f50",
+ "Service_Plan_Name": "WINDOWS_10_ESU_TENANT",
+ "Service_Plan_Id": "a22efeae-e37a-47ac-9a61-1572d74202e5",
+ "Service_Plans_Included_Friendly_Names": "Windows 10 ESU Tenant"
+ },
{
"Product_Display_Name": "Windows 365 Shared Use 4 vCPU 16 GB 128 GB",
"String_Id": "Windows_365_S_4vCPU_16GB_128GB",
diff --git a/src/data/alerts.json b/src/data/alerts.json
index 8f5a2d4cb775..adc8d26de373 100644
--- a/src/data/alerts.json
+++ b/src/data/alerts.json
@@ -209,6 +209,26 @@
"inputName": "OneDriveQuota",
"recommendedRunInterval": "1d"
},
+ {
+ "name": "UnlicensedOneDriveData",
+ "label": "Alert on leftover unlicensed OneDrive data nearing deletion",
+ "recommendedRunInterval": "7d",
+ "requiresInput": true,
+ "multipleInput": true,
+ "inputs": [
+ {
+ "inputType": "number",
+ "inputLabel": "Days until deletion (default: 30)",
+ "inputName": "UnlicensedOneDriveData"
+ },
+ {
+ "inputType": "switch",
+ "inputLabel": "Include shared mailboxes",
+ "inputName": "IncludeSharedMailboxes"
+ }
+ ],
+ "description": "Fires when a still-existing user has an unlicensed OneDrive within the selected days of unpaid deletion (unlicensed start + 365 days). Skips Entra-deleted owners. Shared mailboxes are excluded unless the toggle is on."
+ },
{
"name": "ExpiringLicenses",
"label": "Alert on licenses expiring in X days",
@@ -503,7 +523,7 @@
"name": "HuntressRogueApps",
"label": "Alert on Huntress or CIPP Rogue Apps detected",
"recommendedRunInterval": "4h",
- "description": "Huntress has provided a repository of known rogue apps that are commonly used in BEC, data exfiltration and other Microsoft 365 attacks. This alert will notify you if any of these apps are detected in the selected tenant(s). For more information, see https://huntresslabs.github.io/rogueapps/ . CIPP also has a list of community collected rogue apps.",
+ "description": "Huntress has provided a repository of known rogue apps that are commonly used in BEC, data exfiltration and other Microsoft 365 attacks. This alert will notify you if any of these apps are detected in the selected tenant(s). For more information, see https://huntresslabs.github.io/rogueapps/ . CIPP also maintains its own curated list of rogue apps, so detections may include applications that are not on the Huntress site. See the CIPP documentation for the full list.",
"requiresInput": true,
"inputType": "switch",
"inputLabel": "Ignore Disabled Apps?",
diff --git a/src/data/cipp-roles.json b/src/data/cipp-roles.json
index ac3c389f65a2..750449ac13a6 100644
--- a/src/data/cipp-roles.json
+++ b/src/data/cipp-roles.json
@@ -18,7 +18,8 @@
"CIPP.SuperAdmin.*",
"CIPP.Admin.*",
"CIPP.AppSettings.*",
- "Tenant.Standards.ReadWrite"
+ "Tenant.Standards.ReadWrite",
+ "Tenant.Baselines.ReadWrite"
]
},
"admin": {
diff --git a/src/data/standards.json b/src/data/standards.json
index 560a9eceebe0..57c45550d2fb 100644
--- a/src/data/standards.json
+++ b/src/data/standards.json
@@ -4,7 +4,7 @@
"cat": "Copilot (M365) Standards",
"tag": [],
"helpText": "Configures Microsoft 365 Copilot tenant policy settings: Copilot Chat pinning, blocking Copilot access to open content, Designer image generation, web search, and admin-center Copilot. Each setting can be left unconfigured, enabled, or disabled. These settings are managed through the Copilot policy service (Cloud Policy / Intune) and are applied at the tenant level.",
- "docsDescription": "Manages Microsoft 365 Copilot admin policy settings via the `/copilot/admin/policySettings` Microsoft Graph API (beta). Each of the five supported settings can be independently set or left unmanaged using the \"Do not configure\" option. NOTE: this API currently requires delegated authentication and supports only tenant-level policies; settings scoped to group-level policies return an error and are skipped. The exact accepted value per setting is a string (commonly \"1\"/\"0\") and should be validated against a Copilot-licensed tenant.",
+ "docsDescription": "Manages Microsoft 365 Copilot admin policy settings via the `/copilot/admin/policySettings` Microsoft Graph API (beta). Each of the five supported settings can be independently set or left unmanaged using the \"Do not configure\" option. NOTE: this API currently requires delegated authentication and supports only tenant-level policies; settings scoped to group-level policies return an error and are skipped. Values are strings whose meaning is per-setting, not uniform: web search is three-state (\"0\" enabled everywhere, \"1\" disabled everywhere, \"2\" disabled in Copilot Work mode only) and Designer image generation is inverted (\"1\" disables it, \"0\" enables it). Graph treats these as opaque strings and validates nothing, so do not assume 1=on/0=off for a setting you have not verified against a Copilot-licensed tenant.",
"executiveText": "Provides centralized governance of Microsoft 365 Copilot capabilities across the organization. Administrators can control whether Copilot Chat is pinned for users, whether Copilot can access open files, and whether features such as image generation and web search are available, helping balance employee productivity with data governance and compliance requirements.",
"addedComponent": [
{
@@ -51,11 +51,11 @@
"name": "standards.CopilotSettings.allowWebSearch",
"options": [
{ "label": "Do not configure", "value": "donotconfigure" },
- { "label": "Enabled in Microsoft 365 Copilot and Microsoft 365 Copilot Chat", "value": "2" },
+ { "label": "Enabled in Microsoft 365 Copilot and Microsoft 365 Copilot Chat", "value": "0" },
{ "label": "Disabled in Microsoft 365 Copilot and Microsoft 365 Copilot Chat", "value": "1" },
{
"label": "Disabled in Microsoft 365 Copilot Work mode, Enabled in Microsoft 365 Copilot Chat",
- "value": "0"
+ "value": "2"
}
]
},
@@ -1212,15 +1212,55 @@
"cat": "Entra (AAD) Standards",
"tag": [],
"appliesToTest": ["EIDSCAAT01", "EIDSCAAT02", "ZTNA21845", "ZTNA21846"],
- "helpText": "Enables TAP and sets the default TAP lifetime to 1 hour. This configuration also allows you to select if a TAP is single use or multi-logon.",
+ "helpText": "Enable TAP with the specified configuration settings.",
"docsDescription": "Enables Temporary Access Pass generation for the tenant.",
- "executiveText": "Enables temporary access passes that IT administrators can generate for employees who are locked out or need emergency access to systems. These time-limited passs provide a secure way to restore access without compromising long-term security policies.",
+ "executiveText": "Enables temporary access passes that IT administrators can generate for employees who are locked out or need emergency access to systems. These time-limited passes provide a secure way to restore access without compromising long-term security policies.",
"addedComponent": [
+ {
+ "type": "number",
+ "name": "standards.TAP.MinimumLifetime",
+ "label": "Minimum Lifetime (minutes)",
+ "defaultValue": 60,
+ "validators": {
+ "min": { "value": 10, "message": "Minimum value is 10" },
+ "max": { "value": 43200, "message": "Maximum value is 43200" }
+ }
+ },
+ {
+ "type": "number",
+ "name": "standards.TAP.MaximumLifetime",
+ "label": "Maximum Lifetime (minutes)",
+ "defaultValue": 480,
+ "validators": {
+ "min": { "value": 10, "message": "Minimum value is 10" },
+ "max": { "value": 43200, "message": "Maximum value is 43200" }
+ }
+ },
+ {
+ "type": "number",
+ "name": "standards.TAP.DefaultLifetime",
+ "label": "Default Lifetime (minutes)",
+ "defaultValue": 60,
+ "validators": {
+ "min": { "value": 10, "message": "Minimum value is 10" },
+ "max": { "value": 43200, "message": "Maximum value is 43200" }
+ }
+ },
+ {
+ "type": "number",
+ "name": "standards.TAP.TAPLength",
+ "label": "Length (characters)",
+ "defaultValue": 8,
+ "validators": {
+ "min": { "value": 8, "message": "Minimum value is 8" },
+ "max": { "value": 48, "message": "Maximum value is 48" }
+ }
+ },
{
"type": "autoComplete",
"multiple": false,
"creatable": false,
- "label": "Select TAP Lifetime",
+ "label": "Number of Times Usable",
"name": "standards.TAP.config",
"options": [
{ "label": "Only Once", "value": "true" },
@@ -1353,14 +1393,22 @@
"ZTNA21809",
"ZTNA21869"
],
- "helpText": "Enables App consent admin requests for the tenant via the GA role. Does not overwrite existing reviewer settings",
- "docsDescription": "Enables the ability for users to request admin consent for applications. Should be used in conjunction with the \"Require admin consent for applications\" standards",
+ "helpText": "Enables App consent admin requests for the tenant via the GA role. Optionally adds specific users (matched by display name) as reviewers. Does not overwrite existing reviewer settings",
+ "docsDescription": "Enables the ability for users to request admin consent for applications. Reviewers can be directory roles and/or specific users matched by display name, e.g. a central MSP support account that exists as a guest in each tenant, so each consent request generates a notification to a monitored mailbox. Should be used in conjunction with the \"Require admin consent for applications\" standards",
"executiveText": "Establishes a formal approval process where employees can request access to business applications that require administrative review. This balances security with productivity by allowing controlled access to necessary tools while preventing unauthorized application installations.",
"addedComponent": [
{
"type": "AdminRolesMultiSelect",
"label": "App Consent Reviewer Roles",
"name": "standards.EnableAppConsentRequests.ReviewerRoles"
+ },
+ {
+ "type": "autoComplete",
+ "multiple": true,
+ "creatable": true,
+ "required": false,
+ "label": "Optional: reviewer users (display names of existing users or guests)",
+ "name": "standards.EnableAppConsentRequests.ReviewerUsers"
}
],
"label": "Enable App consent admin requests",
@@ -1387,7 +1435,8 @@
"name": "standards.NudgeMFA.state",
"options": [
{ "label": "Enabled", "value": "enabled" },
- { "label": "Disabled", "value": "disabled" }
+ { "label": "Disabled", "value": "disabled" },
+ { "label": "Microsoft managed", "value": "default" }
]
},
{
@@ -3429,21 +3478,38 @@
"name": "standards.QuarantineRequestAlert",
"cat": "Defender Standards",
"tag": [],
- "helpText": "Sets a e-mail address to alert when a User requests to release a quarantined message.",
- "docsDescription": "Sets a e-mail address to alert when a User requests to release a quarantined message. This is useful for monitoring and ensuring that the correct messages are released.",
+ "helpText": "Sets a e-mail address to alert when a User requests to release a quarantined message. Set the alert state to Removed to delete the alert rule CIPP created from the tenant.",
+ "docsDescription": "Sets a e-mail address to alert when a User requests to release a quarantined message. This is useful for monitoring and ensuring that the correct messages are released. Setting the alert state to Removed deletes the alert rule CIPP created from the tenant, for when the alert is no longer wanted.",
"executiveText": "Notifies IT administrators when employees request to release emails that were quarantined for security reasons, enabling oversight of potentially dangerous messages. This helps ensure that legitimate emails are released while maintaining security controls over suspicious content.",
"addedComponent": [
+ {
+ "type": "autoComplete",
+ "multiple": false,
+ "creatable": false,
+ "required": false,
+ "label": "Alert state (blank or Enabled creates the alert, Removed deletes it)",
+ "name": "standards.QuarantineRequestAlert.state",
+ "options": [
+ { "label": "Enabled", "value": "enabled" },
+ { "label": "Removed", "value": "removed" }
+ ]
+ },
{
"type": "textField",
"name": "standards.QuarantineRequestAlert.NotifyUser",
- "label": "E-mail to receive the alert"
+ "label": "E-mail to receive the alert",
+ "condition": {
+ "field": "standards.QuarantineRequestAlert.state",
+ "compareType": "isNot",
+ "compareValue": { "label": "Removed", "value": "removed" }
+ }
}
],
"label": "Quarantine Release Request Alert",
"impact": "Low Impact",
"impactColour": "info",
"addedDate": "2024-07-15",
- "powershellEquivalent": "New-ProtectionAlert and Set-ProtectionAlert",
+ "powershellEquivalent": "New-ProtectionAlert, Set-ProtectionAlert and Remove-ProtectionAlert",
"recommendedBy": [],
"requiredCapabilities": [
"EXCHANGE_S_STANDARD",
@@ -5107,6 +5173,27 @@
"ONEDRIVE_ENTERPRISE"
]
},
+ {
+ "name": "standards.OneDriveLicensedQuota",
+ "cat": "SharePoint Standards",
+ "tag": [],
+ "helpText": "Raises the OneDrive storage quota to 5 TB for users whose license includes that entitlement. Microsoft provisions every OneDrive at 1 TB and does not apply the licensed entitlement automatically. Users already at or above 5 TB, for example raised further by Microsoft support, are left untouched.",
+ "docsDescription": "Microsoft provisions every OneDrive with a 1 TB quota regardless of license. Users holding OneDrive for Business (Plan 2), SharePoint Online (Plan 2), or a bundle that includes one of these (Microsoft 365/Office 365 E3/E5, A3/A5, G3/G5) are entitled to 5 TB, but an admin has to raise the quota manually. This standard finds enabled users with a qualifying service plan whose OneDrive quota is below 5 TB and raises it to 5 TB, with the warning level at 90%. Users whose quota is already at or above 5 TB, for example increased to 25 TB by Microsoft support, are skipped. Microsoft only permits quotas above 1 TB when the subscription has five or more users on a qualifying plan, so tenants below that threshold are reported as compliant and left unchanged.",
+ "executiveText": "Ensures employees receive the full OneDrive storage their licenses already include. Microsoft grants 5 TB of storage with most enterprise licenses but only provisions 1 TB by default, leaving paid-for capacity unused. Automatically correcting the allocation prevents storage shortages and support tickets without any additional licensing cost.",
+ "addedComponent": [],
+ "label": "Raise OneDrive storage quota for entitled users",
+ "impact": "Low Impact",
+ "impactColour": "info",
+ "addedDate": "2026-08-21",
+ "powershellEquivalent": "Set-SPOSite -Identity https://tenant-my.sharepoint.com/personal/user -StorageQuota 5242880",
+ "recommendedBy": [],
+ "requiredCapabilities": [
+ "SHAREPOINTENTERPRISE",
+ "SHAREPOINTENTERPRISE_EDU",
+ "SHAREPOINTENTERPRISE_GOV",
+ "ONEDRIVEENTERPRISE"
+ ]
+ },
{
"name": "standards.SPFileRequests",
"cat": "SharePoint Standards",
@@ -6021,6 +6108,16 @@
"type": "switch",
"name": "standards.TeamsExternalAccessPolicy.EnableTeamsConsumerAccess",
"label": "Allow communication with unmanaged Teams accounts"
+ },
+ {
+ "type": "switch",
+ "name": "standards.TeamsExternalAccessPolicy.EnableTeamsConsumerInbound",
+ "label": "Allow unmanaged Teams users to initiate contact",
+ "condition": {
+ "field": "standards.TeamsExternalAccessPolicy.EnableTeamsConsumerAccess",
+ "compareType": "is",
+ "compareValue": true
+ }
}
],
"label": "External Access Settings for Microsoft Teams",
@@ -6045,6 +6142,16 @@
"name": "standards.TeamsFederationConfiguration.AllowTeamsConsumer",
"label": "Allow users to communicate with consumer Teams accounts"
},
+ {
+ "type": "switch",
+ "name": "standards.TeamsFederationConfiguration.AllowTeamsConsumerInbound",
+ "label": "Allow unmanaged Teams users to initiate contact",
+ "condition": {
+ "field": "standards.TeamsFederationConfiguration.AllowTeamsConsumer",
+ "compareType": "is",
+ "compareValue": true
+ }
+ },
{
"type": "autoComplete",
"required": true,
@@ -6449,8 +6556,10 @@
"label": "Policy Assignment",
"options": [
{ "label": "Do not assign", "value": "none" },
- { "label": "All devices", "value": "AllDevices" },
- { "label": "All users and devices", "value": "AllDevicesAndUsers" }
+ {
+ "label": "All users (Device Preparation profiles deploy to the enrolling user, so device targets do not apply)",
+ "value": "AllDevicesAndUsers"
+ }
]
}
],
@@ -6461,6 +6570,68 @@
"recommendedBy": [],
"requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"]
},
+ {
+ "name": "standards.AppleEnrollmentTypeProfile",
+ "cat": "Intune Standards",
+ "tag": ["enrollment", "apple", "ios"],
+ "disabledFeatures": { "report": false, "warn": false, "remediate": false },
+ "helpText": "Creates and manages an Apple user-initiated enrollment type profile (such as iOS/iPadOS web based device enrollment) and keeps it assigned to the configured groups. The tenant needs an Apple MDM push certificate for the enrollment itself to function.",
+ "executiveText": "Ensures every tenant offers the same enrollment experience for Apple devices, such as web based enrollment for personal iPhones and iPads, without engineers configuring each tenant by hand. This keeps device onboarding consistent and makes it possible to report on which tenants are correctly configured.",
+ "docsDescription": "Deploys an Apple user-initiated enrollment type profile through deviceManagement/appleUserInitiatedEnrollmentProfiles. The profile is matched by display name; the enrollment type (web based device enrollment, account driven user enrollment, or device enrollment with Company Portal), description and group assignments are kept in sync, with a wrong assignment repaired in place. Priority is only applied when the profile is first created, because reordering is relative to the other profiles in each tenant.",
+ "addedComponent": [
+ {
+ "type": "textField",
+ "name": "standards.AppleEnrollmentTypeProfile.DisplayName",
+ "label": "Profile Display Name",
+ "required": true
+ },
+ {
+ "type": "textField",
+ "name": "standards.AppleEnrollmentTypeProfile.Description",
+ "label": "Profile Description",
+ "required": false
+ },
+ {
+ "type": "autoComplete",
+ "multiple": false,
+ "creatable": false,
+ "name": "standards.AppleEnrollmentTypeProfile.EnrollmentType",
+ "label": "Enrollment Type",
+ "options": [
+ { "label": "Web based device enrollment", "value": "webDeviceEnrollment" },
+ { "label": "Account driven user enrollment", "value": "accountDrivenUserEnrollment" },
+ { "label": "Device enrollment with Company Portal", "value": "device" }
+ ]
+ },
+ {
+ "type": "number",
+ "name": "standards.AppleEnrollmentTypeProfile.Priority",
+ "label": "Priority (applied when the profile is created)",
+ "defaultValue": 1
+ },
+ {
+ "type": "radio",
+ "name": "standards.AppleEnrollmentTypeProfile.AssignTo",
+ "label": "Profile Assignment",
+ "options": [
+ { "label": "Do not assign", "value": "none" },
+ { "label": "Assign to Custom Group", "value": "customGroup" }
+ ]
+ },
+ {
+ "type": "textField",
+ "name": "standards.AppleEnrollmentTypeProfile.customGroup",
+ "label": "Custom group name(s). Comma separated, wildcards allowed.",
+ "required": false
+ }
+ ],
+ "label": "Deploy Apple Enrollment Type Profile",
+ "impact": "Medium Impact",
+ "impactColour": "warning",
+ "addedDate": "2026-08-18",
+ "recommendedBy": [],
+ "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"]
+ },
{
"name": "standards.IntuneTemplate",
"cat": "Templates",
@@ -6937,7 +7108,8 @@
"label": "Select Sensitivity Label Templates",
"api": {
"url": "/api/ListSensitivityLabelTemplates",
- "labelField": "name",
+ "labelField": "DisplayName",
+ "altLabelField": "Name",
"valueField": "GUID",
"queryKey": "ListSensitivityLabelTemplates"
}
@@ -7597,7 +7769,7 @@
"impact": "High Impact",
"impactColour": "danger",
"addedDate": "2026-04-28",
- "powershellEquivalent": "Set-SPOTenant -CustomScriptsRestrictMode $true",
+ "powershellEquivalent": "Portal only",
"recommendedBy": ["CIPP"],
"requiredCapabilities": [
"SHAREPOINTWAC",
@@ -7653,6 +7825,28 @@
"EXCHANGE_LITE"
]
},
+ {
+ "name": "standards.MessageEncryption",
+ "cat": "Exchange Standards",
+ "tag": [],
+ "helpText": "Enables Microsoft Purview Message Encryption by turning on Azure RMS licensing for Exchange Online, and turns on simplified client access so the Encrypt button appears in Outlook on the web and the new Outlook. Skipped with a warning when the tenant still points at an on-premises AD RMS cluster, because AD RMS has to be migrated to Azure RMS first. This standard only turns the feature on: branding, one-time passcodes, and social ID sign-in for encrypted messages are configured in the [Configure Encrypted Message Branding (OME)](https://standards.cipp.app/standards/omebranding) standard. [Read more](https://learn.microsoft.com/en-us/purview/set-up-new-message-encryption-capabilities)",
+ "docsDescription": "Sets AzureRMSLicensingEnabled to true, the prerequisite for Microsoft Purview Message Encryption, and SimplifiedClientAccessEnabled to true so the Encrypt button appears when composing mail in Outlook on the web and the new Outlook. Reports the IRM licensing state per tenant, including the licensing location, so you can see at a glance which tenants have message encryption available. Remediation is deliberately skipped for tenants with an on-premises AD RMS licensing location, as Purview Message Encryption is not compatible with AD RMS and those tenants need to be migrated to Azure RMS first.",
+ "executiveText": "Turns on the built-in encryption that lets staff send protected email to anyone, including recipients outside the organization. Uses licensing the organization already owns, removing the need for a separate secure-email product.",
+ "addedComponent": [],
+ "label": "Enable Purview Message Encryption",
+ "impact": "Low Impact",
+ "impactColour": "info",
+ "addedDate": "2026-08-04",
+ "powershellEquivalent": "Set-IRMConfiguration -AzureRMSLicensingEnabled $true -SimplifiedClientAccessEnabled $true",
+ "recommendedBy": [],
+ "requiredCapabilities": [
+ "EXCHANGE_S_STANDARD",
+ "EXCHANGE_S_ENTERPRISE",
+ "EXCHANGE_S_STANDARD_GOV",
+ "EXCHANGE_S_ENTERPRISE_GOV",
+ "EXCHANGE_LITE"
+ ]
+ },
{
"name": "standards.OMEBranding",
"cat": "Exchange Standards",
diff --git a/src/hooks/use-actions-dispatch.jsx b/src/hooks/use-actions-dispatch.jsx
new file mode 100644
index 000000000000..bad8a7f87968
--- /dev/null
+++ b/src/hooks/use-actions-dispatch.jsx
@@ -0,0 +1,91 @@
+import { useCallback, useState } from "react";
+import { CippApiDialog } from "../components/CippComponents/CippApiDialog";
+import { useDialog } from "./use-dialog";
+import { useSettings } from "./use-settings";
+
+const IDLE = { data: {}, action: {}, ready: false };
+
+/**
+ * Shared dispatch for a page-level `actions` array.
+ *
+ * The desktop ActionsMenu and the mobile page-actions sheet present the same actions two
+ * ways; keeping the confirm-vs-run decision and the dialog wiring here is what stops the
+ * two presentations from drifting apart.
+ *
+ * Note the state is per-instance: two mounted consumers get two dispatchers, so this shares
+ * the decision, not an in-flight dialog.
+ */
+export const useActionsDispatch = ({ actions = [], data, queryKeys }) => {
+ const [actionData, setActionData] = useState(IDLE);
+ const [customAction, setCustomAction] = useState(null);
+ const createDialog = useDialog();
+ const settings = useSettings();
+
+ // Nullsafety for data: it can be undefined (still loading) or null (no data)
+ const isDisabled = (action) => {
+ if (!data) return true;
+ if (action?.condition) return !action.condition(data);
+ return false;
+ };
+
+ const visibleActions = actions?.filter((action) => !action.link || action.showInActionsMenu) ?? [];
+
+ const dispatch = (action) => {
+ // An AllTenants row carries its own tenant; posting under "AllTenants" would target the
+ // wrong one. Page-level data has no Tenant, so this is a no-op there.
+ if (settings?.currentTenant === "AllTenants" && data?.Tenant) {
+ settings.handleUpdate({ currentTenant: data.Tenant });
+ }
+
+ // Run-and-return paths must NOT set ready: doing so mounts CippApiDialog with
+ // api.noConfirm true, and its mount effect auto-submits into the very customFunction
+ // just called here — one tap, two invocations.
+ if (action?.noConfirm && action.customFunction) {
+ action.customFunction(data, action, {});
+ return;
+ }
+ if (typeof action?.customComponent === "function") {
+ setCustomAction({ data, action });
+ return;
+ }
+
+ setActionData({ data, action, ready: true });
+ createDialog.handleOpen();
+ };
+
+ // Dropped once the close transition finishes rather than on close, so the dialog keeps its
+ // exit animation. Leaving it mounted would hold a live mutation, an API subscription and a
+ // form instance for the life of the page — and HeaderedTabbedLayout never unmounts.
+ const handleExited = useCallback(() => setActionData(IDLE), []);
+
+ const dialog = (
+ <>
+ {actionData.ready && (
+
+ )}
+ {customAction?.action?.customComponent(customAction.data, {
+ drawerVisible: Boolean(customAction),
+ setDrawerVisible: (visible) => !visible && setCustomAction(null),
+ fromRowAction: false,
+ })}
+ >
+ );
+
+ return { visibleActions, isDisabled, dispatch, dialog };
+};
diff --git a/src/hooks/use-breakpoint.js b/src/hooks/use-breakpoint.js
new file mode 100644
index 000000000000..dedf377e95a5
--- /dev/null
+++ b/src/hooks/use-breakpoint.js
@@ -0,0 +1,44 @@
+import { useMediaQuery } from "@mui/material";
+import { useSettings } from "./use-settings";
+
+// Shared breakpoint hooks so the two mobile thresholds sit next to each other.
+
+// Chrome pivots where the side nav gives way to the drawer (layouts/index.js). Everything that
+// has to agree with the nav reads this: content gutter, top-nav hamburger, page toolbars.
+export const useIsMobileLayout = () => useMediaQuery((theme) => theme.breakpoints.down("lg"));
+
+// Tables pivot narrower: a table still reads fine at 1100, cards that wide are mostly whitespace.
+export const useIsNarrowForTables = () => useMediaQuery((theme) => theme.breakpoints.down("md"));
+
+export const useIsTabletLayout = () =>
+ useMediaQuery((theme) => theme.breakpoints.between("sm", "md"));
+
+// Settings values can be raw strings or {value,label} autocomplete objects depending on
+// which control wrote them — accept both.
+const unwrap = (setting) => (typeof setting === "object" && setting !== null ? setting.value : setting);
+
+const VALID_MODES = ["auto", "cards", "table"];
+
+/**
+ * Resolves how a CippDataTable should present itself: 'cards' or 'table'.
+ *
+ * Precedence: per-call viewMode prop > settings.tableViewMode > 'auto'.
+ * 'auto' means cards below the md breakpoint, table at or above it.
+ * simple tables are always 'table' — they are 2-3 column embeds that already fit.
+ *
+ * The explicit modes exist for more than preference: jsdom has no width-based
+ * matchMedia, so unit tests drive card mode through this path rather than by
+ * stubbing media queries.
+ */
+export const useTableViewMode = ({ viewMode, simple = false } = {}) => {
+ const settings = useSettings();
+ const isNarrow = useIsNarrowForTables();
+
+ if (simple) return "table";
+
+ let mode = unwrap(viewMode) ?? unwrap(settings?.tableViewMode) ?? "auto";
+ if (!VALID_MODES.includes(mode)) mode = "auto";
+
+ if (mode === "auto") return isNarrow ? "cards" : "table";
+ return mode;
+};
diff --git a/src/hooks/use-history-dismiss.js b/src/hooks/use-history-dismiss.js
new file mode 100644
index 000000000000..633841887496
--- /dev/null
+++ b/src/hooks/use-history-dismiss.js
@@ -0,0 +1,40 @@
+import { useEffect, useRef } from "react";
+import { useRouter } from "next/router";
+import {
+ installOverlayHistory,
+ pushOverlayEntry,
+ releaseOverlayEntry,
+} from "../utils/overlay-history";
+
+/**
+ * Gives an overlay a history entry of its own, so a phone's back gesture dismisses it
+ * instead of navigating the page away.
+ *
+ * useHistoryDismiss(visible, onClose, isMobile);
+ *
+ * The entry is pushed while the overlay is open and popped when it closes for any other
+ * reason, so the history stack is only ever as deep as what is actually on screen.
+ *
+ * Currently used by full-screen mobile surfaces (CippOffCanvas), not bottom sheets: the
+ * release pops synchronously, and the FAB sheet closes itself in the same tick as the
+ * drawer its child opens, which would let the queued back() take the drawer's entry with
+ * it. Registering sheets means deferring the pop so the incoming overlay can reuse the
+ * outgoing one's entry.
+ */
+export const useHistoryDismiss = (open, onClose, enabled = true) => {
+ const router = useRouter();
+ const closeRef = useRef(onClose);
+
+ useEffect(() => {
+ closeRef.current = onClose;
+ }, [onClose]);
+
+ useEffect(() => {
+ // Nothing to hand the gesture to — an overlay with no onClose can't be dismissed, and
+ // claiming a history entry for it would only eat a back press.
+ if (!enabled || !open || typeof closeRef.current !== "function") return undefined;
+ installOverlayHistory(router);
+ const entry = pushOverlayEntry(() => closeRef.current?.());
+ return () => releaseOverlayEntry(entry);
+ }, [enabled, open, router]);
+};
diff --git a/src/hooks/use-sheet-handoff.js b/src/hooks/use-sheet-handoff.js
new file mode 100644
index 000000000000..7846e22c1eae
--- /dev/null
+++ b/src/hooks/use-sheet-handoff.js
@@ -0,0 +1,59 @@
+import { useCallback, useRef } from "react";
+
+/**
+ * Hands a bottom sheet off to the overlay it launches.
+ *
+ * A sheet row that closes the sheet and opens a drawer/dialog in the same tick puts two
+ * MUI Modals in flight at once: the new one registers with the modal manager while the
+ * outgoing Drawer is still transitioning, and when that Drawer finally unmounts it
+ * restores scroll lock, focus and aria-hidden on top of the overlay that just opened —
+ * which reads as the overlay refusing to open, or opening dead.
+ *
+ * Instead, park the callback and run it from the sheet's exit transition:
+ *
+ * const sheet = useSheetHandoff(() => setOpen(false));
+ * sheet.run(() => setDrawerOpen(true))} />
+ *
+ *
+ * `run` still closes the sheet immediately, so the tap feels the same.
+ */
+// Drawer's exit is ~195ms; well past it the sheet is gone whether or not the transition
+// reported in. Running late beats never running, and flush() is idempotent.
+const EXIT_FALLBACK_MS = 400;
+
+export const useSheetHandoff = (close) => {
+ const pendingRef = useRef(null);
+ const fallbackRef = useRef(null);
+
+ const flush = useCallback(() => {
+ if (fallbackRef.current) {
+ clearTimeout(fallbackRef.current);
+ fallbackRef.current = null;
+ }
+ const pending = pendingRef.current;
+ pendingRef.current = null;
+ pending?.();
+ }, []);
+
+ const run = useCallback(
+ (fn) => {
+ pendingRef.current = typeof fn === "function" ? fn : null;
+ if (fallbackRef.current) clearTimeout(fallbackRef.current);
+ fallbackRef.current = setTimeout(flush, EXIT_FALLBACK_MS);
+ close?.();
+ },
+ [close, flush]
+ );
+
+ // Dismissed without picking anything — drop whatever was parked.
+ const cancel = useCallback(() => {
+ pendingRef.current = null;
+ if (fallbackRef.current) {
+ clearTimeout(fallbackRef.current);
+ fallbackRef.current = null;
+ }
+ close?.();
+ }, [close]);
+
+ return { run, handleExited: flush, cancel };
+};
diff --git a/src/hooks/use-swipe-close-transition.js b/src/hooks/use-swipe-close-transition.js
new file mode 100644
index 000000000000..2f83e9b25cb2
--- /dev/null
+++ b/src/hooks/use-swipe-close-transition.js
@@ -0,0 +1,46 @@
+import { useCallback, useEffect, useRef } from "react";
+
+// Slide probes the paper's untranslated position when the exit starts (Slide.js
+// getTranslateValue), so a paper carrying a drag transform snaps wide open and animates the
+// full width out. Re-seed the start position with where the finger let go.
+export const useSwipeCloseTransition = (open, onClose) => {
+ const paperRef = useRef(null);
+ const dragFrom = useRef(null);
+
+ // fires as the open transition starts, so a drag that begins mid-animation still has the node
+ const handleEnter = useCallback((node) => {
+ paperRef.current = node;
+ }, []);
+
+ const handleClose = useCallback(
+ (...args) => {
+ const transform = paperRef.current?.style.transform;
+ dragFrom.current = transform && transform !== "none" ? transform : null;
+ onClose?.(...args);
+ },
+ [onClose]
+ );
+
+ // Effects flush child-first, so this lands after Slide's own exit effect, which runs the same
+ // probe again. Repairing from the transition's onExit callback gets overwritten by it.
+ useEffect(() => {
+ const node = paperRef.current;
+ const from = dragFrom.current;
+ dragFrom.current = null;
+ if (open || !node || !from) {
+ return;
+ }
+ const target = node.style.transform;
+ const transition = node.style.transition;
+ node.style.transition = "none";
+ node.style.transform = from;
+ node.getBoundingClientRect();
+ node.style.transition = transition;
+ node.style.transform = target;
+ }, [open]);
+
+ return {
+ onClose: handleClose,
+ transitionProps: { onEnter: handleEnter },
+ };
+};
diff --git a/src/layouts/HeaderedTabbedLayout.jsx b/src/layouts/HeaderedTabbedLayout.jsx
index d217c9c87d5e..94ef13bcfa42 100644
--- a/src/layouts/HeaderedTabbedLayout.jsx
+++ b/src/layouts/HeaderedTabbedLayout.jsx
@@ -1,11 +1,10 @@
-import { useCallback } from "react";
+import { useCallback, useMemo } from "react";
import { usePathname } from "next/navigation";
import { useRouter } from "next/router";
import PropTypes from "prop-types";
import ArrowLeftIcon from "@heroicons/react/24/outline/ArrowLeftIcon";
import {
Box,
- Button,
Container,
Divider,
Skeleton,
@@ -16,8 +15,13 @@ import {
Typography,
} from "@mui/material";
import { ActionsMenu } from "../components/actions-menu";
-import { useMediaQuery } from "@mui/material";
import { getIconByName } from "../utils/icon-registry";
+import { useIsMobileLayout } from "../hooks/use-breakpoint";
+import { useActionsDispatch } from "../hooks/use-actions-dispatch";
+import { TabNavigationContext, useTabNavigationValue } from "./tab-navigation-context";
+import { CippPageActionsFab } from "../components/CippComponents/CippPageActionsFab";
+import { CippTabPicker } from "../components/CippComponents/CippTabPicker";
+import { ApiGetCall } from "../api/ApiCall";
export const HeaderedTabbedLayout = (props) => {
const {
@@ -27,16 +31,23 @@ export const HeaderedTabbedLayout = (props) => {
subtitle,
actions,
actionsData,
+ // Without this the dispatch falls back to CippApiDialog's hardcoded title, so a header
+ // action mutates successfully and never invalidates the page query.
+ queryKeys,
isFetching = false,
backUrl,
+ // Optional replacement for the title Typography — same slot, same truncation duties.
+ titleControl,
} = props;
- const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md"));
+ // The shared hook rather than an inline useMediaQuery: same threshold, but only this one is
+ // mockable, and jsdom has no width-based matchMedia to drive the mobile branch with.
+ const isMobile = useIsMobileLayout();
const router = useRouter();
const pathname = usePathname();
const queryParams = router.query;
- const handleTabsChange = useCallback(
- (event, value) => {
+ const navigateToTab = useCallback(
+ (value) => {
//if we have query params, we need to append them to the new path
router.push(
{
@@ -47,106 +58,244 @@ export const HeaderedTabbedLayout = (props) => {
{ shallow: true }
);
},
- [router]
+ [router, queryParams]
);
- const currentTab = tabOptions.find((option) => option.path === pathname);
+ const handleTabsChange = useCallback((event, value) => navigateToTab(value), [navigateToTab]);
- return (
-
-
-
-
+ // Feature-flag gating, same rules as TabbedLayout: a DISABLED flag hides its Pages;
+ // an ENABLED flag hides its HidesPages (the pages it replaces - e.g. Baselines
+ // supersedes the classic Standards and Drift tabs on Manage Tenant).
+ const featureFlags = ApiGetCall({
+ url: "/api/ListFeatureFlags",
+ queryKey: "featureFlags",
+ staleTime: 600000,
+ });
+ const visibleTabs = useMemo(() => {
+ if (!featureFlags.isSuccess || !Array.isArray(featureFlags.data)) return tabOptions;
+ const disabledPages = featureFlags.data
+ .filter((flag) => flag.Enabled === false || flag.enabled === false)
+ .flatMap((flag) => flag.Pages || flag.pages || [])
+ .filter((page) => typeof page === "string");
+ const replacedPages = featureFlags.data
+ .filter((flag) => flag.Enabled === true || flag.enabled === true)
+ .flatMap((flag) => flag.HidesPages || flag.hidesPages || [])
+ .filter((page) => typeof page === "string");
+ const hiddenPages = [...disabledPages, ...replacedPages];
+ if (hiddenPages.length === 0) return tabOptions;
+ return tabOptions.filter((option) => !hiddenPages.includes(option.path));
+ }, [tabOptions, featureFlags.isSuccess, featureFlags.data]);
+
+ const currentTab = visibleTabs.find((option) => option.path === pathname);
+
+ // Below md the tab row scrolls horizontally and still hides tabs off the right edge, so
+ // navigation collapses to a picker in the title row — the one part of that row that is
+ // empty at this width, since the Actions menu gets clipped here and moves to the FAB.
+ const actionsDispatch = useActionsDispatch({ actions, data: actionsData, queryKeys });
+ // No isFetching term: the desktop menu's equivalent `disabled` prop is swallowed by
+ // ActionsMenu's unspread ...other, so including it here greyed out every action on mobile
+ // during a background refetch while desktop left them clickable. Actions operate on
+ // stale-but-present data quite happily; aligning down keeps the two surfaces identical
+ // without changing desktop.
+ const { visibleActions, isDisabled, dispatch } = actionsDispatch;
+ const sheetActions = useMemo(
+ () =>
+ isMobile
+ ? visibleActions.map((action) => ({
+ label: action.label,
+ icon: action.icon ? {action.icon} : null,
+ disabled: isDisabled(action),
+ onClick: () => dispatch(action),
+ }))
+ : [],
+ [isMobile, visibleActions, isDisabled, dispatch]
+ );
+
+ const tabNavValue = useTabNavigationValue({
+ tabs: visibleTabs,
+ currentPath: pathname,
+ onNavigate: navigateToTab,
+ actions: sheetActions,
+ enabled: isMobile,
+ providesGutters: true,
+ });
+
+ const subtitleBlock = isFetching ? (
+
+ ) : (
+ subtitle && (
+ // useFlexGap: Stack's default spacing is a margin-left between children, which every
+ // wrapped row inherits — that margin is why the icon/chip pairs sat indented from the
+ // title above them. Gap applies to both axes, so the row gap is set separately or the
+ // stacked pairs end up as far apart vertically as they are horizontally.
+
+ {/* minWidth: 0 down the whole chain, and flexShrink: 0 on the icon. A copy-chip
+ already carries MUI's ellipsis and maxWidth: 100%, but flex items default to
+ min-width: auto, so every ancestor grew to fit instead of letting it truncate —
+ which is how a guest UPN (user_domain.onmicrosoft.com#EXT#@tenant...) ran off the
+ right edge of the screen. */}
+ {subtitle.map((item, index) =>
+ item.component ? (
+
+ {item.component}
+
+ ) : (
+
+ {item.icon}
+
+
+ {item.text}
+
+
+ )
+ )}
+
+ )
+ );
+
+ return (
+
+
+ {/* One gutter for the whole page, matching the layout's breadcrumb rail
+ (mx: {xs: 2, md: 3}): the breadcrumbs, this header's text and the left edge of
+ every card below it then share a single left edge. */}
+
+
+
- {title}
-
- {isFetching ? (
-
- ) : (
- subtitle && (
-
- {subtitle.map((item, index) =>
- item.component ? (
- {item.component}
- ) : (
-
- {item.icon}
-
- {item.text}
-
-
+ {/* minWidth: 0 so a long tenant/entity name truncates in the space the
+ picker leaves rather than pushing it off the right edge of the row.
+ Scoped to the picker's own breakpoint — above md this is unchanged. */}
+
+
+ {/* A name-shaped skeleton, not the word "Loading...": the header is
+ the entity's identity, and a text placeholder reads as a title.
+ titleControl lets a page swap the text for an interactive control
+ in the same clothes (the View User pages mount a user switcher). */}
+ {isFetching ? (
+
+
+
+ ) : (
+ titleControl ?? (
+
+ {title}
+
)
)}
- )
- )}
+ {!isMobile && subtitleBlock}
+
+ {/* The right half of this row is free below md, which is where the tab
+ picker goes. Above md it belongs to the Actions menu, as it always did. */}
+ {isMobile ? (
+
+ ) : (
+ actions &&
+ actions.length > 0 && (
+
+ )
+ )}
+
+ {/* Below md the subtitle gets the full width instead of sharing the title's
+ row: a UPN copy-chip squeezed beside a half-width picker has nowhere to go
+ and runs off the right edge of the screen. */}
+ {isMobile && subtitleBlock}
- {actions && actions.length > 0 && (
-
+ {!isMobile && (
+
+
+ {visibleTabs.map((option) => {
+ const icon = getIconByName(option.icon, { fontSize: "small" });
+ const iconPosition = option.iconPosition ?? "start";
+ const compactIcon = icon && ["end", "start"].includes(iconPosition);
+
+ return (
+
+ );
+ })}
+
+
+
)}
-
-
- {tabOptions.map((option) => {
- const icon = getIconByName(option.icon, { fontSize: "small" });
- const iconPosition = option.iconPosition ?? "start";
- const compactIcon = icon && ["end", "start"].includes(iconPosition);
-
- return (
-
- );
- })}
-
-
-
-
-
- {children}
-
-
-
-
+ >
+ {children}
+
+
+
+
+ {/* Not gated on isMobile: crossing the breakpoint with a dialog open — a rotate, or a
+ tablet at 900px — would unmount it mid-request, taking CippApiResults with it.
+ The hook already renders nothing until an action is dispatched. */}
+ {actionsDispatch.dialog}
+ {/* Actions only, and only when no page FAB claimed the corner — otherwise they ride in
+ that sheet. Tabs are in the title row and never come down here. */}
+ {isMobile && sheetActions.length > 0 && !tabNavValue.isActionCornerClaimed && (
+
+ )}
+
);
};
diff --git a/src/layouts/TabbedLayout.jsx b/src/layouts/TabbedLayout.jsx
index af7403327d53..2ac692e10ce9 100644
--- a/src/layouts/TabbedLayout.jsx
+++ b/src/layouts/TabbedLayout.jsx
@@ -1,10 +1,13 @@
-import { useMemo } from 'react'
+import { useCallback, useMemo } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import { Box, Divider, Stack, Tab, Tabs } from '@mui/material'
import { useSearchParams } from 'next/navigation'
import { ApiGetCall } from '../api/ApiCall'
import { getIconByName } from '../utils/icon-registry'
import { useSettings } from '../hooks/use-settings'
+import { useIsMobileLayout } from '../hooks/use-breakpoint'
+import { TabNavigationContext, useTabNavigationValue } from './tab-navigation-context'
+import { CippTabPicker } from '../components/CippComponents/CippTabPicker'
export const TabbedLayout = (props) => {
const { tabOptions, children } = props
@@ -27,67 +30,102 @@ export const TabbedLayout = (props) => {
if (!featureFlags.isSuccess || !Array.isArray(featureFlags.data)) return tabs
+ // A DISABLED flag hides its Pages; an ENABLED flag hides its HidesPages (the
+ // pages it replaces - e.g. Baselines supersedes the classic Standards tabs).
const disabledPages = featureFlags.data
.filter((flag) => flag.Enabled === false || flag.enabled === false)
.flatMap((flag) => flag.Pages || flag.pages || [])
.filter((page) => typeof page === 'string')
+ const replacedPages = featureFlags.data
+ .filter((flag) => flag.Enabled === true || flag.enabled === true)
+ .flatMap((flag) => flag.HidesPages || flag.hidesPages || [])
+ .filter((page) => typeof page === 'string')
+ const hiddenPages = [...disabledPages, ...replacedPages]
- if (disabledPages.length === 0) return tabs
+ if (hiddenPages.length === 0) return tabs
- return tabs.filter((option) => !disabledPages.includes(option.path))
+ return tabs.filter((option) => !hiddenPages.includes(option.path))
}, [tabOptions, featureFlags.isSuccess, featureFlags.data, showAdvanced])
- const handleTabsChange = (event, value) => {
- // Preserve existing query parameters when changing tabs
- const currentParams = new URLSearchParams(searchParams.toString())
- const queryString = currentParams.toString()
- const newPath = queryString ? `${value}?${queryString}` : value
- router.push(newPath)
- }
+ const navigateToTab = useCallback(
+ (value) => {
+ // Preserve existing query parameters when changing tabs
+ const currentParams = new URLSearchParams(searchParams.toString())
+ const queryString = currentParams.toString()
+ const newPath = queryString ? `${value}?${queryString}` : value
+ router.push(newPath)
+ },
+ [router, searchParams]
+ )
+
+ const handleTabsChange = (event, value) => navigateToTab(value)
const currentTab = visibleTabs.find((option) => option.path === pathname)
+ // Below md the tab row scrolls horizontally and still hides tabs off the right edge, so
+ // navigation collapses to a full-width picker in the slot the tab bar occupied. Always the
+ // layout's own row: a picker that sometimes annexes a heading somewhere on the page and
+ // sometimes doesn't is a control you have to go looking for.
+ const isMobile = useIsMobileLayout()
+ const tabNavValue = useTabNavigationValue({
+ tabs: visibleTabs,
+ currentPath: pathname,
+ onNavigate: navigateToTab,
+ enabled: isMobile,
+ })
+
return (
-
-
-
-
- {visibleTabs.map((option) => {
- const icon = getIconByName(option.icon, { fontSize: 'small' })
- const iconPosition = option.iconPosition ?? 'start'
- const compactIcon = icon && ['end', 'start'].includes(iconPosition)
+
+
+
+ {isMobile && (
+ // pt: 2 nets to the same 16px the sides and the Stack gap below pay: the
+ // breadcrumb divider's mb (8) is cancelled by this layout's mt: -1.
+
+
+
+ )}
+ {!isMobile && (
+
+
+ {visibleTabs.map((option) => {
+ const icon = getIconByName(option.icon, { fontSize: 'small' })
+ const iconPosition = option.iconPosition ?? 'start'
+ const compactIcon = icon && ['end', 'start'].includes(iconPosition)
- return (
-
- )
- })}
-
-
-
- {children}
-
-
+ return (
+
+ )
+ })}
+
+
+
+ )}
+ {children}
+
+
+
)
}
diff --git a/src/layouts/account-popover.js b/src/layouts/account-popover.js
index 444ee7dcd4ac..e27a3aba6e83 100644
--- a/src/layouts/account-popover.js
+++ b/src/layouts/account-popover.js
@@ -2,8 +2,10 @@ import { useCallback } from "react";
import PropTypes from "prop-types";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
+import ArrowPathIcon from "@heroicons/react/24/outline/ArrowPathIcon";
import ArrowRightOnRectangleIcon from "@heroicons/react/24/outline/ArrowRightOnRectangleIcon";
import ChevronDownIcon from "@heroicons/react/24/outline/ChevronDownIcon";
+import MagnifyingGlassIcon from "@heroicons/react/24/outline/MagnifyingGlassIcon";
import MoonIcon from "@heroicons/react/24/outline/MoonIcon";
import SunIcon from "@heroicons/react/24/outline/SunIcon";
import {
@@ -22,22 +24,32 @@ import {
useMediaQuery,
} from "@mui/material";
import { usePopover } from "../hooks/use-popover";
+import { useIsMobileLayout } from "../hooks/use-breakpoint";
+import { useDialog } from "../hooks/use-dialog";
import { paths } from "../paths";
import { ApiGetCall } from "../api/ApiCall";
-import { CogIcon, DocumentTextIcon } from "@heroicons/react/24/outline";
+import { CippApiDialog } from "../components/CippComponents/CippApiDialog";
+import { CogIcon, DocumentTextIcon, LifebuoyIcon, TrashIcon } from "@heroicons/react/24/outline";
+import ArrowTopRightOnSquareIcon from "@heroicons/react/24/outline/ArrowTopRightOnSquareIcon";
import { useReleaseNotes } from "../contexts/release-notes-context";
import { useQueryClient } from "@tanstack/react-query";
+import { usePathname } from "next/navigation";
+import { Divider } from "@mui/material";
+import { getHelpLinks, clearCippCache } from "../utils/help-links";
export const AccountPopover = (props) => {
const {
direction = "ltr",
language = "en",
onThemeSwitch,
+ onOpenSearch,
paletteMode = "light",
...other
} = props;
const router = useRouter();
+ const pathname = usePathname();
const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md"));
+ const navCollapsed = useIsMobileLayout();
const popover = usePopover();
const queryClient = useQueryClient();
const { openReleaseNotes } = useReleaseNotes();
@@ -59,6 +71,11 @@ export const AccountPopover = (props) => {
convertToDataUrl: true,
});
+ // Re-checks Entra group membership server-side, then refetches /api/me so a role granted
+ // through a just-activated PIM group applies without waiting out the role cache. Runs
+ // through the standard confirm dialog, which also renders the API result.
+ const refreshAccessDialog = useDialog();
+
const handleLogout = useCallback(async () => {
try {
popover.handleClose();
@@ -125,6 +142,20 @@ export const AccountPopover = (props) => {
)}
>
+ {orgData.data?.clientPrincipal?.userDetails && (
+
+ )}
{orgData.data?.clientPrincipal?.userDetails && (
{
PaperProps={{ sx: { width: 260 } }}
>
+ {/* Pairs with the trigger above: the identity is either beside the avatar or here. */}
{mdDown && (
+
+
+
+ )}
+ {/* Home for the two bar icons top-nav drops at navCollapsed (useIsMobileLayout),
+ so they stay reachable wherever the bar isn't showing them. */}
+ {navCollapsed && (
<>
-
-
-
+ {onOpenSearch && (
+ {
+ popover.handleClose();
+ onOpenSearch();
+ }}
+ >
+
+
+
+
+
+
+
+ )}
{ popover.handleClose(); onThemeSwitch(); }}>
@@ -177,6 +228,62 @@ export const AccountPopover = (props) => {
+ {/* Mobile home for the help SpeedDial's destinations — its FAB corner belongs
+ to page actions there (the SpeedDial hides itself below md). */}
+ {mdDown && (
+ <>
+
+ {getHelpLinks(pathname ?? "").map((link) => (
+ {
+ popover.handleClose();
+ window.open(link.href, "_blank");
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+ ))}
+ {
+ popover.handleClose();
+ clearCippCache(queryClient);
+ }}
+ >
+
+
+
+
+
+
+
+
+ >
+ )}
+ {
+ popover.handleClose();
+ refreshAccessDialog.handleOpen();
+ }}
+ >
+
+
+
+
+
+
+
diff --git a/src/layouts/config.js b/src/layouts/config.js
index 3be081ae159a..c54b951f3ec4 100644
--- a/src/layouts/config.js
+++ b/src/layouts/config.js
@@ -44,6 +44,11 @@ export const nativeMenuItems = [
path: '/identity/administration/users',
permissions: ['Identity.User.*'],
},
+ {
+ title: 'Guest Users',
+ path: '/identity/administration/guest-users',
+ permissions: ['Identity.User.*'],
+ },
{
title: 'Risky Users',
path: '/identity/administration/risky-users',
@@ -211,41 +216,32 @@ export const nativeMenuItems = [
permissions: ['Tenant.Relationship.*'],
scope: 'global',
},
+ // Flag-gated swap: the Baselines feature flag lists this path in its Pages
+ // (hidden while the flag is off) and the classic Standards/Drift paths in
+ // HidesPages (hidden while it is on) - the two never show together.
+ {
+ title: 'Baselines',
+ path: '/tenant/baselines',
+ permissions: ['Tenant.Baselines.*'],
+ scope: 'global',
+ },
+ {
+ title: 'Domains Analyser',
+ path: '/tenant/standards/domains-analyser',
+ permissions: ['Tenant.DomainAnalyser.*'],
+ scope: 'global',
+ },
{
title: 'Standards & Drift',
- permissions: [
- 'Tenant.Standards.*',
- 'Tenant.BestPracticeAnalyser.*',
- 'Tenant.DomainAnalyser.*',
- ],
- items: [
- {
- title: 'Standards Management',
- path: '/tenant/standards/alignment',
- permissions: ['Tenant.Standards.*'],
- scope: 'global',
- },
- // Baselines mockup - hidden from the nav for now; reach it directly
- // at /tenant/baselines
- // {
- // title: 'Baselines (Preview)',
- // path: '/tenant/baselines',
- // permissions: ['Tenant.Standards.*'],
- // scope: 'global',
- // },
- {
- title: 'Best Practice Analyser',
- path: '/tenant/standards/bpa-report',
- permissions: ['Tenant.BestPracticeAnalyser.*'],
- scope: 'global',
- },
- {
- title: 'Domains Analyser',
- path: '/tenant/standards/domains-analyser',
- permissions: ['Tenant.DomainAnalyser.*'],
- scope: 'global',
- },
- ],
+ path: '/tenant/standards/alignment',
+ permissions: ['Tenant.Standards.*'],
+ scope: 'global',
+ },
+ {
+ title: 'Best Practice Analyser',
+ path: '/tenant/standards/bpa-report',
+ permissions: ['Tenant.BestPracticeAnalyser.*'],
+ scope: 'global',
},
{
title: 'Conditional Access',
@@ -1086,6 +1082,11 @@ export const nativeMenuItems = [
path: '/email/tools/mailbox-restores',
permissions: ['Exchange.Mailbox.*'],
},
+ {
+ title: 'Message Encryption',
+ path: '/email/tools/message-encryption',
+ permissions: ['Exchange.Mailbox.*'],
+ },
],
},
{
diff --git a/src/layouts/constants.js b/src/layouts/constants.js
index ee450b86e3dd..37c513ad06cc 100644
--- a/src/layouts/constants.js
+++ b/src/layouts/constants.js
@@ -7,8 +7,7 @@
export const TOP_NAV_HEIGHT = 64
export const SIDE_NAV_WIDTH = 290
-export const SIDE_NAV_PINNED_WIDTH = 50
-export const SIDE_NAV_COLLAPSED_WIDTH = 73 // icon size + padding + border right
+export const SIDE_NAV_COLLAPSED_WIDTH = 73 // icon size + padding + border right; also the unpinned content offset
// Height of the hosted maintenance banner, published by CippMaintenanceBanner via a CSS custom
// property on :root so the fixed chrome can offset itself without prop threading. Resolves to 0px
diff --git a/src/layouts/index.js b/src/layouts/index.js
index 510a2c62a4aa..db2db462a98a 100644
--- a/src/layouts/index.js
+++ b/src/layouts/index.js
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useState, useRef } from 'react'
import { usePathname } from 'next/navigation'
-import { Box, Container, Divider, Stack, useMediaQuery } from '@mui/material'
+import { Box, Container, Divider, Stack } from '@mui/material'
import { styled } from '@mui/material/styles'
+import { useIsMobileLayout } from '../hooks/use-breakpoint'
import { useSettings } from '../hooks/use-settings'
import { Footer } from './footer'
import { MobileNav } from './mobile-nav'
@@ -19,10 +20,11 @@ import { ForcedSsoMigrationDialog } from '../components/CippComponents/ForcedSso
import { SubscriptionEndedDialog } from '../components/CippComponents/SubscriptionEndedDialog'
import { FailedPaymentDialog } from '../components/CippComponents/FailedPaymentDialog'
import { CippMaintenanceBanner } from '../components/CippComponents/CippMaintenanceBanner'
+import { CippImpersonationBanner } from '../components/CippComponents/CippImpersonationBanner'
import {
BANNER_HEIGHT_VAR,
- SIDE_NAV_PINNED_WIDTH,
+ SIDE_NAV_COLLAPSED_WIDTH,
SIDE_NAV_WIDTH,
TOP_NAV_HEIGHT,
} from './constants'
@@ -56,6 +58,9 @@ const useMobileNav = () => {
}
}
+// No breakpoint paddingLeft here: the side-nav offset is applied once, via the inline
+// `sx` on the rendered LayoutRoot (it depends on pinNav). A second static rule at lg+
+// used to fight that dynamic one over the same property.
const LayoutRoot = styled('div')(({ theme }) => ({
backgroundColor: theme.palette.background.default,
display: 'flex',
@@ -64,9 +69,6 @@ const LayoutRoot = styled('div')(({ theme }) => ({
height: '100vh',
overflow: 'hidden',
paddingTop: `calc(${TOP_NAV_HEIGHT}px + ${BANNER_HEIGHT_VAR})`,
- [theme.breakpoints.up('lg')]: {
- paddingLeft: SIDE_NAV_WIDTH,
- },
}))
const LayoutContainer = styled('div')({
@@ -82,7 +84,8 @@ export const Layout = (props) => {
// showBreadcrumb: the error routes opt out — there is no trail to a page that
// doesn't exist or just crashed, and the bookmark button lives in there too.
const { children, allTenantsSupport = true, showBreadcrumb = true } = props
- const mdDown = useMediaQuery((theme) => theme.breakpoints.down('md'))
+ // one gate for the swap: drawer, the hamburger that opens it (top-nav), the gutter below
+ const navCollapsed = useIsMobileLayout()
const settings = useSettings()
const mobileNav = useMobileNav()
const [fetchingVisible, setFetchingVisible] = useState([])
@@ -120,20 +123,28 @@ export const Layout = (props) => {
return
}
- // Get disabled pages from feature flags - only filter if we have valid data
- let disabledPages = []
+ // Get hidden pages from feature flags - only filter if we have valid data.
+ // A DISABLED flag hides its Pages (features gated behind the flag); an ENABLED
+ // flag hides its HidesPages (features it replaces - e.g. Baselines supersedes
+ // the classic Standards and Drift pages).
+ let hiddenPages = []
if (featureFlags.isSuccess && Array.isArray(featureFlags.data)) {
- disabledPages = featureFlags.data
+ const disabledPages = featureFlags.data
.filter((flag) => flag.Enabled === false || flag.enabled === false)
.flatMap((flag) => flag.Pages || flag.pages || [])
.filter((page) => typeof page === 'string')
+ const replacedPages = featureFlags.data
+ .filter((flag) => flag.Enabled === true || flag.enabled === true)
+ .flatMap((flag) => flag.HidesPages || flag.hidesPages || [])
+ .filter((page) => typeof page === 'string')
+ hiddenPages = [...disabledPages, ...replacedPages]
}
const filterItemsByRole = (items) => {
return items
.map((item) => {
- // Check if page is disabled by feature flag
- if (item.path && disabledPages.length > 0 && disabledPages.includes(item.path)) {
+ // Check if page is hidden by feature flag
+ if (item.path && hiddenPages.length > 0 && hiddenPages.includes(item.path)) {
return null
}
@@ -203,7 +214,9 @@ export const Layout = (props) => {
})
}, [settings])
- const offset = settings.pinNav ? SIDE_NAV_WIDTH : SIDE_NAV_PINNED_WIDTH
+ // Unpinned content offset must match the collapsed drawer's real width — the old 50px
+ // constant left 23px of content underneath the 73px rail.
+ const offset = settings.pinNav ? SIDE_NAV_WIDTH : SIDE_NAV_COLLAPSED_WIDTH
const userSettingsAPI = ApiGetCall({
url: '/api/ListUserSettings',
@@ -303,19 +316,26 @@ export const Layout = (props) => {
<>
{/* Rendered outside the hideSidebar check - maintenance applies to chrome-less pages too. */}
+
{hideSidebar === false && (
<>
- {mdDown && (
-
+ {navCollapsed && (
+
)}
- {!mdDown && }
+ {!navCollapsed && }
>
)}
@@ -331,7 +351,7 @@ export const Layout = (props) => {
-
+
{
) : (
- {showBreadcrumb && (
- <>
-
-
-
-
- >
- )}
+ {/* The nav carries its own rail chrome (gutter + divider) so that when it
+ renders nothing — a single crumb on a phone — no hairline is left behind. */}
+ {showBreadcrumb && }
{children}
)}
diff --git a/src/layouts/mobile-nav-item.js b/src/layouts/mobile-nav-item.js
index 961d01ee33be..4367198a7ad7 100644
--- a/src/layouts/mobile-nav-item.js
+++ b/src/layouts/mobile-nav-item.js
@@ -24,6 +24,9 @@ export const MobileNavItem = (props) => {
const isGlobal = scope === "global";
const [open, setOpen] = useState(openImmediately);
+ // same step as side-nav-item, nesting reads the same in both navs
+ const indent = depth > 0 ? depth * 1.5 : 1;
+
const handleToggle = useCallback(() => {
setOpen((prevOpen) => !prevOpen);
}, []);
@@ -43,7 +46,7 @@ export const MobileNavItem = (props) => {
fontSize: 14,
fontWeight: 500,
justifyContent: 'flex-start',
- px: '6px',
+ px: `${indent * 6}px`,
py: '12px',
textAlign: 'left',
whiteSpace: 'nowrap',
@@ -119,7 +122,7 @@ export const MobileNavItem = (props) => {
fontSize: 14,
fontWeight: 500,
justifyContent: 'flex-start',
- px: '6px',
+ px: `${indent * 6}px`,
py: '12px',
textAlign: 'left',
whiteSpace: 'nowrap',
diff --git a/src/layouts/mobile-nav.js b/src/layouts/mobile-nav.js
index 334914c822db..264b7cd97afe 100644
--- a/src/layouts/mobile-nav.js
+++ b/src/layouts/mobile-nav.js
@@ -1,18 +1,23 @@
+import { useMemo, useState } from "react";
import NextLink from "next/link";
import { usePathname } from "next/navigation";
import PropTypes from "prop-types";
-import { Box, Divider, Drawer, Stack } from "@mui/material";
+import { Box, Divider, InputAdornment, OutlinedInput, Stack, SwipeableDrawer, Typography } from "@mui/material";
+import { Search } from "@mui/icons-material";
import { Logo } from "../components/logo";
+import { CippSponsor } from "../components/CippComponents/CippSponsor";
import { Scrollbar } from "../components/scrollbar";
import { paths } from "../paths";
import { MobileNavItem } from "./mobile-nav-item";
import { SideNavBookmarks } from "./side-nav-bookmarks";
-import { CippTenantSelector } from "../components/CippComponents/CippTenantSelector";
import { useSettings } from "../hooks/use-settings";
+import { useSwipeCloseTransition } from "../hooks/use-swipe-close-transition";
-const MOBILE_NAV_WIDTH = "80%";
+// 80% of the viewport truncated third-level labels at 320px (256px) and was absurd at
+// 899px (719px). Cap it like a real nav drawer.
+const MOBILE_NAV_WIDTH = "min(360px, 88vw)";
-const renderItems = ({ depth = 0, items, pathname }) =>
+const renderItems = ({ depth = 0, items, pathname, forceOpen = false }) =>
items.reduce(
(acc, item) =>
reduceChildRoutes({
@@ -20,11 +25,12 @@ const renderItems = ({ depth = 0, items, pathname }) =>
depth,
item,
pathname,
+ forceOpen,
}),
[]
);
-const reduceChildRoutes = ({ acc, depth, item, pathname }) => {
+const reduceChildRoutes = ({ acc, depth, item, pathname, forceOpen }) => {
const checkPath = !!(item.path && pathname);
// Special handling for root path "/" to avoid matching all paths
const partialMatch = checkPath && item.path !== "/" ? pathname.includes(item.path) : false;
@@ -37,8 +43,9 @@ const reduceChildRoutes = ({ acc, depth, item, pathname }) => {
depth={depth}
external={item.external}
icon={item.icon}
- key={item.title}
- openImmediately={partialMatch}
+ // Search results re-render with a different key so collapse state resets open
+ key={`${item.title}-${forceOpen ? "open" : "closed"}`}
+ openImmediately={forceOpen || partialMatch}
path={item.path}
scope={item.scope}
title={item.title}
@@ -56,6 +63,7 @@ const reduceChildRoutes = ({ acc, depth, item, pathname }) => {
depth: depth + 1,
items: item.items,
pathname,
+ forceOpen,
})}
@@ -78,60 +86,120 @@ const reduceChildRoutes = ({ acc, depth, item, pathname }) => {
return acc;
};
+// Prune the nav tree to items whose title matches the query, keeping ancestors of matches.
+// A matching branch keeps its whole subtree so its children stay reachable.
+const filterNavItems = (items, query) =>
+ items.reduce((acc, item) => {
+ const selfMatch = item.title?.toLowerCase().includes(query);
+ if (item.items) {
+ if (selfMatch) {
+ acc.push(item);
+ return acc;
+ }
+ const filteredChildren = filterNavItems(item.items, query);
+ if (filteredChildren.length > 0) {
+ acc.push({ ...item, items: filteredChildren });
+ }
+ return acc;
+ }
+ if (selfMatch) {
+ acc.push(item);
+ }
+ return acc;
+ }, []);
+
export const MobileNav = (props) => {
- const { open, onClose, items } = props;
+ const { open, onClose, onOpen, items } = props;
const pathname = usePathname();
const settings = useSettings();
+ const swipeClose = useSwipeCloseTransition(open, onClose);
+ const [search, setSearch] = useState("");
const showSidebarBookmarks = settings.bookmarkSidebar !== false;
+ const query = search.trim().toLowerCase();
+ const visibleItems = useMemo(
+ () => (query ? filterNavItems(items ?? [], query) : (items ?? [])),
+ [items, query]
+ );
+
return (
- {})}
open={open}
+ slotProps={{ transition: swipeClose.transitionProps }}
PaperProps={{
sx: {
+ // desktop side-nav renders on background.default, keep the drawer on the same surface
+ backgroundColor: "background.default",
width: MOBILE_NAV_WIDTH,
+ // Column layout so the sponsor footer pins to the bottom and the menu scrolls
+ // between it and the sticky header, rather than the footer riding the list.
+ display: "flex",
+ flexDirection: "column",
},
}}
variant="temporary"
>
+ {/* Sticky header: logo (relocated from the mobile top bar) + nav search */}
+
+
+
+
+ setSearch(event.target.value)}
+ inputProps={{ enterKeyHint: "search", "aria-label": "Search navigation" }}
+ startAdornment={
+
+
+
+ }
+ sx={{ minHeight: 44 }}
+ />
+
-
-
-
-
-
-
-
-
{
}}
>
{/* Bookmarks section above Dashboard */}
- {showSidebarBookmarks && (
+ {showSidebarBookmarks && !query && (
<>
@@ -153,17 +221,36 @@ export const MobileNav = (props) => {
{/* Render all menu items */}
{renderItems({
depth: 0,
- items,
+ items: visibleItems,
pathname,
+ forceOpen: Boolean(query),
})}
+ {query && visibleItems.length === 0 && (
+
+ No pages match “{search}”.
+
+ )}
-
+ {/* Pinned below the scrolling menu rather than at the end of it, so it stays visible
+ without the long nav list pushing it off-screen. Compact: the drawer's vertical
+ space belongs to navigation. */}
+
+
+
+
);
};
MobileNav.propTypes = {
onClose: PropTypes.func,
+ onOpen: PropTypes.func,
open: PropTypes.bool,
};
diff --git a/src/layouts/notifications-popover.js b/src/layouts/notifications-popover.js
index dddbf442555d..b585b6871d5e 100644
--- a/src/layouts/notifications-popover.js
+++ b/src/layouts/notifications-popover.js
@@ -88,7 +88,22 @@ export const NotificationsPopover = () => {
return (
<>
-
+
diff --git a/src/layouts/side-nav-bookmarks.js b/src/layouts/side-nav-bookmarks.js
index 0ae0ec7abdec..08bba90d2b84 100644
--- a/src/layouts/side-nav-bookmarks.js
+++ b/src/layouts/side-nav-bookmarks.js
@@ -16,10 +16,15 @@ import ChevronDownIcon from "@heroicons/react/24/outline/ChevronDownIcon";
import { useSettings } from "../hooks/use-settings";
import { useUserBookmarks } from "../hooks/use-user-bookmarks";
-export const SideNavBookmarks = ({ collapse = false }) => {
+// alignWithRail: the pinned side nav sits beside the content area's breadcrumb rail, and the
+// two header rows share a divider line across the seam — the desktop nav passes this so the
+// Bookmarks row matches the rail's 28px row instead of the 48px nav-item rhythm. The mobile
+// drawer has no rail beside it and keeps the roomier row.
+export const SideNavBookmarks = ({ collapse = false, alignWithRail = false }) => {
const settings = useSettings();
const compactNav = settings.compactNav ?? false;
const navItemPy = compactNav ? "6px" : "12px";
+ const headerPy = alignWithRail ? "2px" : navItemPy;
const emptyStatePy = compactNav ? "4px" : "8px";
const { bookmarks, setBookmarks } = useUserBookmarks();
const [open, setOpen] = useState(settings.bookmarksOpen ?? false);
@@ -190,7 +195,7 @@ export const SideNavBookmarks = ({ collapse = false }) => {
fontWeight: 500,
justifyContent: "flex-start",
px: "6px",
- py: navItemPy,
+ py: headerPy,
textAlign: "left",
whiteSpace: "nowrap",
width: "100%",
diff --git a/src/layouts/side-nav.js b/src/layouts/side-nav.js
index cd8cd5ce834d..5181643c64e8 100644
--- a/src/layouts/side-nav.js
+++ b/src/layouts/side-nav.js
@@ -227,6 +227,9 @@ export const SideNav = (props) => {
flexDirection: 'column',
height: '100%',
p: 2,
+ // The breadcrumb rail across the seam starts 10px under the top nav; starting
+ // the Bookmarks header at the same offset lets the two rows share a line.
+ pt: '10px',
}}
>
{
{/* Bookmarks section above Dashboard */}
{showSidebarBookmarks && (
<>
-
-
+
+ {/* mt matches the rail row's mb: 1, so the dividers meet across the seam */}
+
>
)}
{/* Render all menu items */}
diff --git a/src/layouts/tab-navigation-context.js b/src/layouts/tab-navigation-context.js
new file mode 100644
index 000000000000..69b175921e4c
--- /dev/null
+++ b/src/layouts/tab-navigation-context.js
@@ -0,0 +1,116 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useId,
+ useMemo,
+ useState,
+} from 'react'
+import { useIsMobileLayout } from '../hooks/use-breakpoint'
+
+/**
+ * Lets a tabbed layout publish its tab list — and, on the headered variant, its page actions.
+ *
+ * Below md the scrollable tab row costs a band of vertical space and still hides tabs off the
+ * right edge, so navigation collapses to a picker in the content flow (CippTabPicker). That
+ * picker is always drawn by the layout, so there is nothing to negotiate over it.
+ *
+ * The FAB corner is different: it fits exactly one FAB, and about a quarter of tabbed pages
+ * already grow one from a table's `cardButton`. A headered layout therefore hands its actions
+ * to that FAB rather than adding a second one — hence the claim registry below.
+ */
+export const TabNavigationContext = createContext(null)
+
+export const useTabNavigation = () => useContext(TabNavigationContext)
+
+/**
+ * Claims the bottom-right corner while `active`. A claimant takes responsibility for making
+ * the layout's actions reachable — or for deliberately withholding them, as the card list does
+ * while its select-mode bulk bar owns the bottom of the screen.
+ */
+export const useActionCornerClaim = (active) => {
+ const context = useContext(TabNavigationContext)
+ const claimId = useId()
+ const claim = context?.claim
+ const release = context?.release
+
+ useEffect(() => {
+ if (!active || !claim || !release) return undefined
+ claim(claimId)
+ return () => release(claimId)
+ }, [active, claim, release, claimId])
+}
+
+/**
+ * True when the mobile tab picker already names this page. The picker trigger wears the
+ * current tab's label in heading clothes directly above the page header, so a page whose own
+ * title is the same string would print it twice in a row. The page keeps its title on
+ * desktop, where the tab bar looks like navigation rather than a heading.
+ */
+export const useTitleClaimedByTabPicker = (title) => {
+ const context = useContext(TabNavigationContext)
+ const isMobile = useIsMobileLayout()
+ // Mirrors CippTabPicker's own render conditions: below two destinations it draws nothing,
+ // so there is no trigger to claim the title.
+ if (!isMobile || !context?.enabled || (context.tabs?.length ?? 0) < 2 || !title) return false
+ const current = context.tabs.find((tab) => tab.path === context.currentPath)
+ return current?.label?.trim().toLowerCase() === String(title).trim().toLowerCase()
+}
+
+/**
+ * Builds the context value for a tabbed layout. `tabs` are the already-filtered options
+ * ({label, path, icon}); `onNavigate` receives a path.
+ */
+export const useTabNavigationValue = ({
+ tabs,
+ currentPath,
+ onNavigate,
+ actions = [],
+ enabled,
+ // HeaderedTabbedLayout wraps its children in a Container; TabbedLayout does not. Content
+ // that renders its own Container (CippFormPage) reads this so the two don't double up.
+ providesGutters = false,
+}) => {
+ const [claims, setClaims] = useState([])
+
+ // An aliased route (pages/index.js re-exports the dashboard, so it renders at "/") matches
+ // no tab path — which left the picker labelled "Views" with nothing checked. The page an
+ // alias re-exports is one of these tabs, and in practice the first: treat it as current.
+ const resolvedPath = tabs?.some((tab) => tab.path === currentPath)
+ ? currentPath
+ : (tabs?.[0]?.path ?? currentPath)
+
+ const claim = useCallback((id) => {
+ setClaims((prev) => (prev.includes(id) ? prev : [...prev, id]))
+ }, [])
+
+ const release = useCallback((id) => {
+ setClaims((prev) => prev.filter((claimId) => claimId !== id))
+ }, [])
+
+ return useMemo(
+ () => ({
+ enabled,
+ tabs,
+ currentPath: resolvedPath,
+ onNavigate,
+ actions,
+ providesGutters,
+ claim,
+ release,
+ isActionCornerClaimed: claims.length > 0,
+ }),
+ [
+ enabled,
+ tabs,
+ resolvedPath,
+ onNavigate,
+ actions,
+ providesGutters,
+ claim,
+ release,
+ claims.length,
+ ]
+ )
+}
diff --git a/src/layouts/top-nav.js b/src/layouts/top-nav.js
index f3de8a1920e7..f796bc222996 100644
--- a/src/layouts/top-nav.js
+++ b/src/layouts/top-nav.js
@@ -26,7 +26,6 @@ import {
Stack,
SvgIcon,
Tooltip,
- useMediaQuery,
Popover,
List,
ListItem,
@@ -35,11 +34,13 @@ import {
} from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { Logo } from '../components/logo'
+import { useIsMobileLayout } from '../hooks/use-breakpoint'
import { useSettings } from '../hooks/use-settings'
import { useUserBookmarks } from '../hooks/use-user-bookmarks'
import { paths } from '../paths'
import { AccountPopover } from './account-popover'
import { CippTenantSelector } from '../components/CippComponents/CippTenantSelector'
+import { CippMobileTenantPicker } from '../components/CippComponents/CippMobileTenantPicker'
import { NotificationsPopover } from './notifications-popover'
import { useDialog } from '../hooks/use-dialog'
import { CippUniversalSearchV2 } from '../components/CippCards/CippUniversalSearchV2'
@@ -53,7 +54,8 @@ export const TopNav = (props) => {
const { onNavOpen } = props
const settings = useSettings()
const { bookmarks, setBookmarks } = useUserBookmarks()
- const mdDown = useMediaQuery((theme) => theme.breakpoints.down('md'))
+ // same gate as the side nav in layouts/index.js, the hamburger below is the drawer's only opener
+ const navCollapsed = useIsMobileLayout()
const showPopoverBookmarks = settings.bookmarkPopover === true
const reorderMode = settings.bookmarkReorderMode || 'arrows'
const locked = settings.bookmarkLocked ?? true
@@ -263,35 +265,44 @@ export const TopNav = (props) => {
alignItems="center"
sx={{
minHeight: TOP_NAV_HEIGHT,
- px: 3,
+ // Mobile: the 24px desktop inset pushed the hamburger far off the left edge —
+ // an 8px inset puts the ☰ glyph on the content gutter line.
+ px: { xs: 1, md: 3 },
}}
>
+ navCollapsed ? undefined : (
+
+ )
}
>
-
-
-
- {!mdDown && (
+ {/* On phones the logo gives way to the tenant chip — the app's primary scoping
+ control earns the space a 24px decorative link was using. */}
+ {!navCollapsed && (
+
+
+
+ )}
+ {!navCollapsed && (
{
/>
)}
- {mdDown && (
-
+ {navCollapsed && (
+
)}
+ {navCollapsed && (
+
+
+
+ )}
-
- {!mdDown && (
+ {/* 0.5 left the notification dot and the account avatar sharing the same few pixels */}
+
+ {!navCollapsed && (
{
)}
- {!mdDown && (
+ {!navCollapsed && (
{effectivePaletteMode === 'dark' ? : }
)}
- {!mdDown && (
+ {!navCollapsed && (
{
)}
+ {/* Mobile: no search icon in the bar — the tenant chip is the more important
+ control and gets the width. Universal search lives in the account menu. */}
{showPopoverBookmarks && (
<>
@@ -627,17 +651,18 @@ export const TopNav = (props) => {
open={universalSearchDialog.open}
onClose={closeUniversalSearch}
fullWidth
+ fullScreen={navCollapsed}
maxWidth="md"
sx={{
'& .MuiDialog-container': {
alignItems: 'flex-start',
},
'& .MuiDialog-paper': {
- mt: 8,
+ mt: navCollapsed ? 0 : 8,
},
}}
>
-
+
{
useFlexGap
spacing={1}
>
- Universal Search
-
- Pages: Ctrl/Cmd+K · Users: Ctrl/Cmd+Shift+F · Tenant: Ctrl/Cmd+Alt+K
-
+
+ {/* Fullscreen on mobile leaves no backdrop to tap — provide a close button */}
+ {navCollapsed && (
+
+
+
+ )}
+ Universal Search
+
+ {!navCollapsed && (
+
+ Pages: Ctrl/Cmd+K · Users: Ctrl/Cmd+Shift+F · Tenant: Ctrl/Cmd+Alt+K
+
+ )}
@@ -680,6 +719,7 @@ export const TopNav = (props) => {
openUniversalSearch('Pages')}
paletteMode={effectivePaletteMode === 'light' ? 'dark' : 'light'}
/>
diff --git a/src/pages/_app.js b/src/pages/_app.js
index c0d6d5e0daeb..a3f69ebe2727 100644
--- a/src/pages/_app.js
+++ b/src/pages/_app.js
@@ -53,9 +53,12 @@ import {
AutoStories,
Gavel,
ClearAll as ClearAllIcon,
+ SupportAgent,
+ FiberManualRecord,
} from '@mui/icons-material'
import { School as TutorialIcon } from '@mui/icons-material'
-import { SvgIcon } from '@mui/material'
+import { getHelpLinks, clearCippCache } from '../utils/help-links'
+import { Chip, SvgIcon } from '@mui/material'
import React, { useEffect, useState, useRef } from 'react'
import { usePathname } from 'next/navigation'
import { useRouter } from 'next/router'
@@ -63,6 +66,7 @@ import { persistQueryClient } from '@tanstack/react-query-persist-client'
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'
import { TutorialProvider } from '../contexts/tutorial-context'
import CippTutorialDialog from '../components/CippComponents/CippTutorialDialog'
+import CippSupportBundleDialog from '../components/CippComponents/CippSupportBundleDialog'
const ReactQueryDevtoolsProduction = React.lazy(() =>
import('@tanstack/react-query-devtools/build/modern/production.js').then((d) => ({
@@ -91,6 +95,8 @@ const App = (props) => {
const route = useRouter()
const [dateLocale, setDateLocale] = useState(enUS)
const [tutorialDialogOpen, setTutorialDialogOpen] = useState(false)
+ const [supportBundleOpen, setSupportBundleOpen] = useState(false)
+ const [supportRecording, setSupportRecording] = useState(false)
useEffect(() => {
if (typeof window === 'undefined') return
@@ -195,29 +201,21 @@ const App = (props) => {
}
}, [])
+ // Link/cache destinations are shared with AccountPopover's mobile help section — see
+ // utils/help-links.js. Only the icons and SpeedDial-specific actions live here.
+ const helpLinkIcons = {
+ 'bug-report': ,
+ 'feature-request': ,
+ discord: ,
+ documentation: ,
+ }
+
const speedDialActions = [
{
- // add clear cache action that removes the persisted query cache from local storage and reloads the page
id: 'clearCache',
icon: ,
name: 'Clear Cache and Reload',
- onClick: () => {
- // Clear the TanStack Query cache
- queryClient.clear()
-
- // Remove persisted cache from localStorage
- if (typeof window !== 'undefined') {
- // Remove the persisted query cache keys
- Object.keys(localStorage).forEach((key) => {
- if (key.startsWith('REACT_QUERY_OFFLINE_CACHE')) {
- localStorage.removeItem(key)
- }
- })
- }
-
- // Force refresh the page to bypass browser cache and reload JavaScript
- window.location.reload(true)
- },
+ onClick: () => clearCippCache(queryClient),
},
{
id: 'license',
@@ -227,38 +225,16 @@ const App = (props) => {
onClick: () => route.push('/license'),
},
{
- id: 'bug-report',
- icon: ,
- name: 'Report Bug',
- href: 'https://github.com/CyberDrain/CIPP/issues/new?template=bug.yml',
- onClick: () =>
- window.open('https://github.com/CyberDrain/CIPP/issues/new?template=bug.yml', '_blank'),
- },
- {
- id: 'feature-request',
- icon: ,
- name: 'Request Feature',
- href: 'https://github.com/CyberDrain/CIPP/issues/new?template=feature.yml',
- onClick: () =>
- window.open(
- 'https://github.com/CyberDrain/CIPP/issues/new?template=feature.yml',
- '_blank'
- ),
- },
- {
- id: 'discord',
- icon: ,
- name: 'Join the Discord!',
- href: 'https://discord.gg/cyberdrain',
- onClick: () => window.open('https://discord.gg/cyberdrain', '_blank'),
- },
- {
- id: 'documentation',
- icon: ,
- name: 'Check the Documentation',
- href: `https://docs.cipp.app/user-documentation${pathname}`,
- onClick: () => window.open(`https://docs.cipp.app/user-documentation${pathname}`, '_blank'),
+ id: 'supportBundle',
+ icon: ,
+ name: 'Generate Support File',
+ onClick: () => setSupportBundleOpen(true),
},
+ ...getHelpLinks(pathname).map((link) => ({
+ ...link,
+ icon: helpLinkIcons[link.id],
+ onClick: () => window.open(link.href, '_blank'),
+ })),
{
id: 'tutorials',
icon: ,
@@ -305,10 +281,34 @@ const App = (props) => {
open={tutorialDialogOpen}
onClose={() => setTutorialDialogOpen(false)}
/>
+ setSupportBundleOpen(false)}
+ onRecordingChange={setSupportRecording}
+ />
+ {supportRecording && !supportBundleOpen && (
+ }
+ label="Recording — click to stop"
+ color="error"
+ onClick={() => setSupportBundleOpen(true)}
+ sx={{
+ position: 'fixed',
+ bottom: 20,
+ // Pinned left of the speed dial FAB (46px wide + 12px gap),
+ // which itself shifts left when devtools is enabled.
+ right:
+ (settings.isInitialized && settings?.showDevtools === true
+ ? 60
+ : 12) + 58,
+ zIndex: (muiTheme) => muiTheme.zIndex.speedDial,
+ }}
+ />
+ )}
}
diff --git a/src/pages/cipp/advanced/authentication/cipp-roles/index.js b/src/pages/cipp/advanced/authentication/cipp-roles/index.js
index b759ddfa29b6..fc2c7e20aab4 100644
--- a/src/pages/cipp/advanced/authentication/cipp-roles/index.js
+++ b/src/pages/cipp/advanced/authentication/cipp-roles/index.js
@@ -3,19 +3,20 @@ import { Layout as DashboardLayout } from "../../../../../layouts/index.js";
import tabOptions from "../tabOptions";
import CippPageCard from "../../../../../components/CippCards/CippPageCard";
import CippRoles from "../../../../../components/CippSettings/CippRoles";
-import { CardContent, Stack, Alert } from "@mui/material";
+import { CippExpandableAlert } from "../../../../../components/CippComponents/CippExpandableAlert";
+import { CardContent, Stack } from "@mui/material";
const Page = () => {
return (
-
+
Custom roles can be used to restrict permissions for users with the 'editor' or
'readonly' roles in CIPP. They can be limited to a subset of tenants and API
permissions. Built-in and custom roles can be assigned to Entra security groups for
granular access control.
-
+
diff --git a/src/pages/cipp/advanced/authentication/cipp-users.js b/src/pages/cipp/advanced/authentication/cipp-users.js
index ad3b6097c147..c2d37ea3f344 100644
--- a/src/pages/cipp/advanced/authentication/cipp-users.js
+++ b/src/pages/cipp/advanced/authentication/cipp-users.js
@@ -3,14 +3,17 @@ import { Layout as DashboardLayout } from "../../../../layouts/index.js";
import tabOptions from "./tabOptions";
import CippPageCard from "../../../../components/CippCards/CippPageCard";
import { CippUserManagement } from "../../../../components/CippSettings/CippUserManagement";
-import { CardContent, Stack, Alert } from "@mui/material";
+import { CippExpandableAlert } from "../../../../components/CippComponents/CippExpandableAlert";
+import { CardContent, Stack } from "@mui/material";
const Page = () => {
return (
-
+ // Titled to match the tab label, so the mobile picker claims the heading and the page
+ // does not say "CIPP Users" and "CIPP User Management" back to back.
+
-
+
Manage users who can access CIPP. Users are automatically synced from your partner
tenant every 15 minutes based on Entra group memberships configured on the CIPP Roles
page. You can also manually add users or assign additional roles — manual assignments
@@ -23,7 +26,7 @@ const Page = () => {
to access CIPP, you can add them as guest users in your partner tenant and assign them the
appropriate roles in CIPP or enable the multi tenant mode in the CIPP SSO tab and add the users
to the list below without needing to add them as guest users in your tenant.
-
+
diff --git a/src/pages/cipp/advanced/authentication/sso.js b/src/pages/cipp/advanced/authentication/sso.js
index fc5b112f3f1c..ec8cb012df9a 100644
--- a/src/pages/cipp/advanced/authentication/sso.js
+++ b/src/pages/cipp/advanced/authentication/sso.js
@@ -7,7 +7,7 @@ import { CippSSOSettings } from "../../../../components/CippSettings/CippSSOSett
const Page = () => {
return (
-
+
diff --git a/src/pages/cipp/advanced/container-management/logs.js b/src/pages/cipp/advanced/container-management/logs.js
index 9b5682001207..44f7745e5388 100644
--- a/src/pages/cipp/advanced/container-management/logs.js
+++ b/src/pages/cipp/advanced/container-management/logs.js
@@ -23,6 +23,7 @@ import { CippTablePage } from "../../../../components/CippComponents/CippTablePa
import { ApiGetCall } from "../../../../api/ApiCall";
import defaultPresets from "../../../../data/ContainerLogPresets.json";
import tabOptions from "./tabOptions";
+import { CippExpandableAlert } from "../../../../components/CippComponents/CippExpandableAlert";
const levelOptions = [
{ label: "All Levels", value: "" },
@@ -249,7 +250,7 @@ const ContainerLogsFilter = ({ onSubmitFilter }) => {
{tabValue === 0 && (
-
+
Query Syntax
Use a KQL-inspired pipe syntax to filter container logs. Separate clauses with{" "}
@@ -279,7 +280,7 @@ const ContainerLogsFilter = ({ onSubmitFilter }) => {
search all files — include rotated logs
-
+
@@ -310,7 +311,7 @@ const ContainerLogsFilter = ({ onSubmitFilter }) => {
}}
/>
-
+
{
-
+
}>
Search Logs
diff --git a/src/pages/cipp/advanced/container-management/worker-health.js b/src/pages/cipp/advanced/container-management/worker-health.js
index b1eb44776b4d..27a5b422cc75 100644
--- a/src/pages/cipp/advanced/container-management/worker-health.js
+++ b/src/pages/cipp/advanced/container-management/worker-health.js
@@ -64,6 +64,7 @@ import { CippInfoBar } from "../../../../components/CippCards/CippInfoBar";
import { CippDataTable } from "../../../../components/CippTable/CippDataTable";
import { ApiGetCall, ApiPostCall } from "../../../../api/ApiCall";
import tabOptions from "./tabOptions";
+import { useTitleClaimedByTabPicker } from "../../../../layouts/tab-navigation-context";
const formatDuration = (ms) => {
if (ms === 0 || ms == null) return "—";
@@ -352,6 +353,8 @@ const CompactStatsRow = ({ snapshot }) => {
{ k: "Queued", v: jobs.Queued ?? 0, w: jobs.Queued > 10 },
{ k: "Done", v: jobs.Completed?.toLocaleString() ?? 0 },
{ k: "Failed", v: jobs.Failed ?? 0, w: jobs.Failed > 0 },
+ // Stale queue entries whose task was gone by dispatch time — benign, so never flagged.
+ { k: "Skipped", v: jobs.Skipped ?? 0 },
],
},
{
@@ -459,6 +462,7 @@ const HistoryChart = ({ data, rangeMinutes, title, icon, children }) => {
const Page = () => {
const theme = useTheme();
+ const titleClaimed = useTitleClaimedByTabPicker("Worker Health");
const queryClient = useQueryClient();
const fileInputRef = useRef(null);
const [historyRange, setHistoryRange] = useState(60);
@@ -720,7 +724,9 @@ const Page = () => {
{/* ── Header toolbar ── */}
- Worker Health
+ {/* Empty Box keeps the toolbar on the right when the mobile tab picker has
+ already said "Worker Health" directly above this row. */}
+ {titleClaimed ? : Worker Health }
{isImported && (
{
}}
simpleColumns={jobSimpleColumns}
actions={jobActions}
+ offCanvas={{
+ extendedInfoFields: [
+ "Id",
+ "Name",
+ "RunName",
+ "Status",
+ "Priority",
+ "QueuedUtc",
+ "StartedUtc",
+ "CompletedUtc",
+ "WaitSeconds",
+ "DurationSeconds",
+ "LastError",
+ ],
+ }}
defaultSorting={[{ id: "QueuedUtc", desc: true }]}
cardButton={
@@ -846,7 +867,7 @@ const Page = () => {
onChange={(_, val) => val !== null && setJobStatus(val)}
size="small"
>
- {["", "Queued", "Running", "Completed", "Failed", "Cancelled"].map((s) => (
+ {["", "Queued", "Running", "Completed", "Failed", "Cancelled", "Skipped"].map((s) => (
{s || "All"}
@@ -1145,7 +1166,7 @@ const Page = () => {
/>
{/* Stats row */}
-
+
{cacheStats.map((s) => {
const cell = (
diff --git a/src/pages/cipp/advanced/super-admin/jit-admin-settings.js b/src/pages/cipp/advanced/super-admin/jit-admin-settings.js
index fa6401e9b7c3..514b5b097c07 100644
--- a/src/pages/cipp/advanced/super-admin/jit-admin-settings.js
+++ b/src/pages/cipp/advanced/super-admin/jit-admin-settings.js
@@ -7,6 +7,7 @@ import { Typography, Alert } from "@mui/material";
import { Grid } from "@mui/system";
import CippFormComponent from "../../../../components/CippComponents/CippFormComponent";
import { ApiGetCall } from "../../../../api/ApiCall";
+import { CippExpandableAlert } from "../../../../components/CippComponents/CippExpandableAlert";
import { useEffect } from "react";
const Page = () => {
@@ -103,7 +104,7 @@ const Page = () => {
-
+
Important Notes:
@@ -121,7 +122,7 @@ const Page = () => {
This setting applies globally to all tenants and all JIT admin creations
-
+
diff --git a/src/pages/cipp/advanced/table-maintenance.js b/src/pages/cipp/advanced/table-maintenance.js
index fea95d6e3774..a6f83e4dfe96 100644
--- a/src/pages/cipp/advanced/table-maintenance.js
+++ b/src/pages/cipp/advanced/table-maintenance.js
@@ -66,8 +66,15 @@ const CustomAddEditRowDialog = ({ formControl, open, onClose, onSubmit, defaultV
{Array.isArray(fields) && fields?.length > 0 && (
<>
{fields.map((field, index) => (
-
-
+
+
-
+
handleTypeChange(index, e.target.value)}
@@ -88,7 +95,7 @@ const CustomAddEditRowDialog = ({ formControl, open, onClose, onSubmit, defaultV
Boolean
-
+
{
that should only be used when directed by CyberDrain support.
-
+
{
}
/>
-
+
{selectedTable && (
diff --git a/src/pages/cipp/custom-data/schema-extensions/index.js b/src/pages/cipp/custom-data/schema-extensions/index.js
index fab26e70f51c..17faba908d40 100644
--- a/src/pages/cipp/custom-data/schema-extensions/index.js
+++ b/src/pages/cipp/custom-data/schema-extensions/index.js
@@ -6,6 +6,7 @@ import { Add, Block, CheckCircleOutline } from "@mui/icons-material";
import tabOptions from "../tabOptions";
import { TrashIcon } from "@heroicons/react/24/outline";
import NextLink from "next/link";
+import { CippExpandableAlert } from "../../../../components/CippComponents/CippExpandableAlert";
const Page = () => {
const pageTitle = "Schema Extensions";
@@ -107,7 +108,7 @@ const Page = () => {
+
{
There is a limit of 5 total schema extensions.
-
+
}
cardButton={
{
if (daysDifference > 10) {
return (
-
+
You have selected a date range of {Math.ceil(daysDifference)} days. Large date ranges
may cause timeouts or errors due to the amount of data being processed. Consider
@@ -151,14 +151,22 @@ const Page = () => {
tableFilter={
setExpanded(!expanded)}>
}>
-
+
-
+
Logbook Filters
{filterEnabled ? (
-
+
(
{startDate || endDate ? (
<>
@@ -179,11 +187,19 @@ const Page = () => {
{username && <>User: {username}>}
{severity && (username || startDate || endDate) && ' | '}
{severity && <>Severity: {severity.replace(/,/g, ', ')}>})
-
+
) : (
-
+
(Today: {new Date().toLocaleDateString()})
-
+
)}
@@ -192,7 +208,7 @@ const Page = () => {
}
+ dataSourceControls={reportDB.controls}
/>
{reportDB.syncDialog}
>
diff --git a/src/pages/endpoint/MEM/list-scripts/index.jsx b/src/pages/endpoint/MEM/list-scripts/index.jsx
index 2661e040c2a4..0a1d6ead381d 100644
--- a/src/pages/endpoint/MEM/list-scripts/index.jsx
+++ b/src/pages/endpoint/MEM/list-scripts/index.jsx
@@ -502,7 +502,7 @@ const Page = () => {
actions={actions}
offCanvas={offCanvas}
simpleColumns={simpleColumns}
- cardButton={reportDB.controls}
+ dataSourceControls={reportDB.controls}
/>
diff --git a/src/pages/endpoint/MEM/reusable-settings/index.js b/src/pages/endpoint/MEM/reusable-settings/index.js
index 75219f0d4136..b42086aa1afa 100644
--- a/src/pages/endpoint/MEM/reusable-settings/index.js
+++ b/src/pages/endpoint/MEM/reusable-settings/index.js
@@ -76,9 +76,9 @@ const Page = () => {
cardButton={
- {reportDB.controls}
}
+ dataSourceControls={reportDB.controls}
apiUrl={reportDB.resolvedApiUrl}
queryKey={reportDB.resolvedQueryKey}
actions={actions}
diff --git a/src/pages/endpoint/applications/list/index.js b/src/pages/endpoint/applications/list/index.js
index 232e55a21dae..cdbbc0d97baa 100644
--- a/src/pages/endpoint/applications/list/index.js
+++ b/src/pages/endpoint/applications/list/index.js
@@ -386,9 +386,9 @@ const Page = () => {
}>
Sync VPP
- {reportDB.controls}
}
+ dataSourceControls={reportDB.controls}
/>
{
const steps = [
{
- title: 'Step 1',
+ title: 'Deployment Type',
+ description: 'Deployment Type',
+ component: CippWizardAutopilotTypeSelection,
+ },
+ {
+ title: 'Tenant Selection',
description: 'Tenant Selection',
component: CippTenantStep,
componentProps: {
@@ -17,9 +24,10 @@ const Page = () => {
},
},
{
- title: 'Step 2',
+ title: 'Autopilot Device Import',
description: 'Device Import',
component: CippWizardAutopilotImport,
+ showStepWhen: (values) => values?.deploymentType !== 'devicePrep',
componentProps: {
name: 'autopilotData',
fields: [
@@ -58,20 +66,66 @@ const Page = () => {
},
},
{
- title: 'Step 3',
+ title: 'Autopilot Options',
description: 'Extra Options',
component: CippWizardAutopilotOptions,
+ showStepWhen: (values) => values?.deploymentType !== 'devicePrep',
},
{
- title: 'Step 4',
+ title: 'Corporate Identifier Import',
+ description: 'Device Import',
+ component: CippWizardDevicePrepImport,
+ showStepWhen: (values) => values?.deploymentType === 'devicePrep',
+ componentProps: {
+ name: 'devicePrepData',
+ fields: [
+ {
+ friendlyName: 'Manufacturer',
+ propertyName: 'manufacturer',
+ alternativePropertyNames: [
+ 'Manufacturer name',
+ 'oemManufacturerName',
+ ],
+ },
+ {
+ friendlyName: 'Model',
+ propertyName: 'model',
+ alternativePropertyNames: ['Device model', 'modelName'],
+ },
+ {
+ friendlyName: 'Serial Number',
+ propertyName: 'serialNumber',
+ alternativePropertyNames: [
+ 'Serial number',
+ 'Device Serial Number',
+ 'SerialNumber',
+ ],
+ },
+ ],
+ fileName: 'corporate-identifiers-template',
+ },
+ },
+ {
+ title: 'Autopilot Confirmation',
description: 'Confirmation',
component: CippWizardConfirmation,
+ showStepWhen: (values) => values?.deploymentType !== 'devicePrep',
+ },
+ {
+ title: 'Device Prep Confirmation',
+ description: 'Confirmation',
+ component: CippWizardConfirmation,
+ showStepWhen: (values) => values?.deploymentType === 'devicePrep',
+ componentProps: {
+ postUrl: '/api/AddCorporateDeviceIdentifier',
+ },
},
]
return (
<>
{
group.mail}
+ />
+ }
actions={groupActions}
actionsData={data}
subtitle={subtitle}
@@ -699,7 +721,7 @@ const Page = () => {
>
-
+
@@ -806,7 +828,7 @@ const Page = () => {
-
+
Members
{
const pageTitle = 'Groups'
- const [showMembers, setShowMembers] = useState(false)
- const [showOwners, setShowOwners] = useState(false)
const { currentTenant } = useSettings()
+ const tenantQuery =
+ currentTenant === 'AllTenants' ? '[Tenant]' : currentTenant
+ const nestedTenantQuery =
+ currentTenant === 'AllTenants' ? '[parent.Tenant]' : currentTenant
const reportDB = useCippReportDB({
apiUrl: '/api/ListGroups',
@@ -35,25 +39,10 @@ const Page = () => {
cacheColumns: ['CacheTimestamp'],
})
- const handleMembersToggle = () => {
- setShowMembers((prev) => {
- const next = !prev
- if (next) setShowOwners(false)
- return next
- })
- }
-
- const handleOwnersToggle = () => {
- setShowOwners((prev) => {
- const next = !prev
- if (next) setShowMembers(false)
- return next
- })
- }
const actions = [
{
label: 'View Group',
- link: `/identity/administration/groups/group?groupId=[id]&tenantFilter=${currentTenant}`,
+ link: `/identity/administration/groups/group?groupId=[id]&tenantFilter=${tenantQuery}`,
color: 'info',
icon: ,
multiPost: false,
@@ -66,6 +55,81 @@ const Page = () => {
icon: ,
color: 'success',
},
+ {
+ label: 'Add Member',
+ type: 'POST',
+ url: '/api/EditGroup',
+ icon: ,
+ customDataformatter: (row, action, formData) => {
+ // Members picked in the dialog already carry {label, value: id, addedFields}
+ const addMember = [...(formData.AddMember ?? [])]
+ // CSV rows only carry a userPrincipalName; without a value the backend
+ // resolves the directory object id itself
+ ;(formData.bulkMember ?? []).forEach((csvRow) => {
+ const upnKey = Object.keys(csvRow).find(
+ (key) => key.trim().toLowerCase() === 'userprincipalname'
+ )
+ const userPrincipalName = upnKey ? csvRow[upnKey]?.trim() : undefined
+ if (userPrincipalName) {
+ addMember.push({
+ label: userPrincipalName,
+ addedFields: { userPrincipalName: userPrincipalName },
+ })
+ }
+ })
+
+ // Handle multiple groups - return an array of requests (one per group)
+ const selectedGroups = Array.isArray(row) ? row : [row]
+ return selectedGroups.map((group) => ({
+ AddMember: addMember,
+ tenantFilter: getRowTenant(group, currentTenant),
+ groupId: group.id,
+ groupName: group.displayName,
+ groupType: group.groupType,
+ }))
+ },
+ fields: [
+ {
+ type: 'autoComplete',
+ name: 'AddMember',
+ label: 'Select users to add as members',
+ multiple: true,
+ creatable: false,
+ api: {
+ url: '/api/ListGraphRequest',
+ data: {
+ Endpoint: 'users',
+ $select: 'id,displayName,userPrincipalName',
+ $top: 999,
+ $count: true,
+ },
+ dataKey: 'Results',
+ labelField: (user) => `${user.displayName} (${user.userPrincipalName})`,
+ valueField: 'id',
+ addedField: {
+ userPrincipalName: 'userPrincipalName',
+ displayName: 'displayName',
+ },
+ queryKey: 'ListUsersAutoComplete',
+ showRefresh: true,
+ },
+ validators: {
+ validate: (value, formValues) =>
+ (Array.isArray(value) && value.length > 0) ||
+ (Array.isArray(formValues.bulkMember) && formValues.bulkMember.length > 0) ||
+ 'Select at least one user or upload a CSV',
+ },
+ },
+ {
+ type: 'CSVReader',
+ name: 'bulkMember',
+ },
+ ],
+ confirmText:
+ 'Select the users to add as members to [displayName], or drop a CSV file with a userPrincipalName column to bulk add members.',
+ multiPost: false,
+ allowResubmit: true,
+ },
{
label: 'Set Global Address List Visibility',
type: 'POST',
@@ -360,16 +424,6 @@ const Page = () => {
title={pageTitle}
cardButton={
- {!reportDB.useReportDB && (
- <>
-
- {showMembers ? 'Hide Members' : 'Show Members'}
-
-
- {showOwners ? 'Hide Owners' : 'Show Owners'}
-
- >
- )}
}>
Add Group
@@ -380,27 +434,13 @@ const Page = () => {
>
Deploy Group Template
- {reportDB.controls}
}
+ dataSourceControls={reportDB.controls}
apiUrl={reportDB.resolvedApiUrl}
- apiData={
- reportDB.useReportDB
- ? undefined
- : showMembers
- ? { expandMembers: true }
- : showOwners
- ? { expandOwners: true }
- : {}
- }
+ apiData={reportDB.useReportDB ? undefined : {}}
queryKey={
- reportDB.useReportDB
- ? reportDB.resolvedQueryKey
- : showMembers
- ? `groups-with-members-${currentTenant}`
- : showOwners
- ? `groups-with-owners-${currentTenant}`
- : `groups-${currentTenant}`
+ reportDB.useReportDB ? reportDB.resolvedQueryKey : `groups-${currentTenant}`
}
actions={actions}
offCanvas={offCanvas}
@@ -419,6 +459,148 @@ const Page = () => {
'onPremisesSamAccountName',
'membershipRule',
'onPremisesSyncEnabled',
+ 'members',
+ 'owners',
+ ]}
+ subTables={[
+ {
+ id: 'members',
+ header: 'Members',
+ label: 'View members',
+ cachedColumn: 'membersCsv',
+ table: {
+ title: 'Members of [displayName]',
+ queryKey: 'group-members-[id]',
+ api: {
+ url: '/api/ListGroups',
+ data: { groupID: '[id]', members: true, groupType: '[groupType]' },
+ dataKey: 'members',
+ },
+ simpleColumns: ['displayName', 'userPrincipalName', 'mail', '@odata.type'],
+ actions: [
+ {
+ label: 'View User',
+ link: `/identity/administration/users/user?userId=[id]&tenantFilter=${nestedTenantQuery}`,
+ color: 'info',
+ icon: ,
+ condition: (row) =>
+ !row?.['@odata.type'] || row['@odata.type'] === '#microsoft.graph.user',
+ },
+ {
+ label: 'View Group',
+ link: `/identity/administration/groups/group?groupId=[id]&tenantFilter=${nestedTenantQuery}`,
+ color: 'info',
+ icon: ,
+ condition: (row) => row?.['@odata.type'] === '#microsoft.graph.group',
+ },
+ {
+ label: 'Remove Member',
+ type: 'POST',
+ url: '/api/ExecGroupMembers',
+ icon: ,
+ data: { action: '!removeMember', groupId: 'parent.id', users: 'id' },
+ confirmText: 'Remove [displayName] from [parent.displayName]?',
+ condition: (row) =>
+ !row?.parent?.dynamicGroupBool && !row?.parent?.membershipRule,
+ },
+ ],
+ cardButton: {
+ label: 'Add Members',
+ icon: ,
+ url: '/api/ExecGroupMembers',
+ allowResubmit: true,
+ relatedQueryKeys: 'group-members-[id]',
+ confirmText: 'Add members to [displayName]?',
+ condition: (row) => !row?.dynamicGroupBool && !row?.membershipRule,
+ data: { action: '!addMember', groupId: 'id' },
+ fields: [
+ {
+ type: 'autoComplete',
+ name: 'users',
+ label: 'Add Members',
+ multiple: true,
+ creatable: false,
+ csvColumn: 'userPrincipalName',
+ api: {
+ url: '/api/ListUsersAndGroups',
+ dataKey: 'Results',
+ valueField: 'id',
+ labelField: 'displayName',
+ descriptionField: 'userPrincipalName',
+ },
+ },
+ ],
+ },
+ },
+ },
+ {
+ id: 'owners',
+ header: 'Owners',
+ label: 'View owners',
+ cachedColumn: 'ownersCsv',
+ table: {
+ title: 'Owners of [displayName]',
+ queryKey: 'group-owners-[id]',
+ api: {
+ url: '/api/ListGroups',
+ data: { groupID: '[id]', owners: true, groupType: '[groupType]' },
+ dataKey: 'owners',
+ },
+ simpleColumns: ['displayName', 'userPrincipalName', 'mail'],
+ actions: [
+ {
+ label: 'View User',
+ link: `/identity/administration/users/user?userId=[id]&tenantFilter=${nestedTenantQuery}`,
+ color: 'info',
+ icon: ,
+ condition: (row) =>
+ !row?.['@odata.type'] || row['@odata.type'] === '#microsoft.graph.user',
+ },
+ {
+ label: 'Remove Owner',
+ type: 'POST',
+ url: '/api/ExecGroupMembers',
+ icon: ,
+ data: { action: '!removeOwner', groupId: 'parent.id', users: 'id' },
+ confirmText: 'Remove [displayName] as owner of [parent.displayName]?',
+ },
+ ],
+ cardButton: {
+ label: 'Add Owners',
+ icon: ,
+ url: '/api/ExecGroupMembers',
+ allowResubmit: true,
+ relatedQueryKeys: 'group-owners-[id]',
+ confirmText: 'Add owners to [displayName]?',
+ data: { action: '!addOwner', groupId: 'id' },
+ fields: [
+ {
+ type: 'autoComplete',
+ name: 'users',
+ label: 'Add Owners',
+ multiple: true,
+ creatable: false,
+ csvColumn: 'userPrincipalName',
+ api: {
+ url: '/api/ListGraphRequest',
+ dataKey: 'Results',
+ valueField: 'id',
+ labelField: 'displayName',
+ descriptionField: 'userPrincipalName',
+ data: {
+ Endpoint: 'users',
+ manualPagination: true,
+ $select: 'id,userPrincipalName,displayName',
+ $count: true,
+ $orderby: 'displayName',
+ $top: 999,
+ },
+ },
+ },
+ ],
+ },
+ },
+ },
]}
/>
{reportDB.syncDialog}
diff --git a/src/pages/identity/administration/guest-users/index.js b/src/pages/identity/administration/guest-users/index.js
new file mode 100644
index 000000000000..dad96ee43dc7
--- /dev/null
+++ b/src/pages/identity/administration/guest-users/index.js
@@ -0,0 +1,243 @@
+import { useMemo, useState } from 'react'
+import { Layout as DashboardLayout } from '../../../../layouts/index.js'
+import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx'
+import { ApiGetCallWithPagination } from '../../../../api/ApiCall'
+import { useSettings } from '../../../../hooks/use-settings'
+import { useCippReportDB } from '../../../../components/CippComponents/CippReportDBControls'
+import {
+ Card,
+ CardActionArea,
+ CardContent,
+ Skeleton,
+ Stack,
+ Typography,
+} from '@mui/material'
+import { Box, Grid } from '@mui/system'
+import { EyeIcon } from '@heroicons/react/24/outline'
+import {
+ Block,
+ CheckCircle,
+ GroupOutlined,
+ HourglassEmpty,
+ PersonOff,
+ Send,
+ WarningAmber,
+} from '@mui/icons-material'
+
+const GUEST_STATUSES = [
+ { status: 'Active', color: 'success', icon: CheckCircle },
+ { status: 'Stale', color: 'error', icon: WarningAmber },
+ { status: 'Pending Acceptance', color: 'warning', icon: HourglassEmpty },
+ { status: 'Never Signed In', color: 'info', icon: PersonOff },
+ { status: 'Disabled', color: 'secondary', icon: Block },
+]
+
+const SummaryCard = ({
+ title,
+ count,
+ icon: Icon,
+ color,
+ selected,
+ isFetching,
+ onClick,
+}) => (
+
+
+
+
+
+
+
+ {isFetching ? : count}
+
+
+ {title}
+
+
+
+
+
+
+)
+
+const Page = () => {
+ const pageTitle = 'Guest Users'
+ const currentTenant = useSettings().currentTenant
+ const [statusFilter, setStatusFilter] = useState(null)
+
+ const reportDB = useCippReportDB({
+ apiUrl: '/api/ListGuestUsers',
+ queryKey: 'ListGuestUsers',
+ cacheName: 'Guests',
+ syncTitle: 'Sync Guest Users',
+ allowToggle: true,
+ defaultCached: true,
+ allowAllTenantSync: true,
+ cacheColumns: ['CacheTimestamp'],
+ })
+
+ // Same url/data/queryKey as the table below, so react-query shares one request
+ // between the summary cards and the table.
+ const guestData = ApiGetCallWithPagination({
+ url: reportDB.resolvedApiUrl,
+ data: { tenantFilter: currentTenant },
+ queryKey: reportDB.resolvedQueryKey,
+ waiting: true,
+ })
+
+ const guests = useMemo(
+ () =>
+ guestData.data?.pages?.flatMap((page) =>
+ Array.isArray(page) ? page : []
+ ) ?? [],
+ [guestData.data]
+ )
+
+ const statusCounts = useMemo(() => {
+ const counts = {}
+ for (const guest of guests) {
+ counts[guest.status] = (counts[guest.status] ?? 0) + 1
+ }
+ return counts
+ }, [guests])
+
+ // The trailing column-format entry drives the table's status filter from the
+ // summary cards; an empty value clears it again. The named presets surface the
+ // same one-click filters in the table's filter menu.
+ const filterList = useMemo(
+ () => [
+ ...GUEST_STATUSES.map(({ status }) => ({
+ filterName: `${status} guests`,
+ value: [{ id: 'status', value: status }],
+ type: 'column',
+ })),
+ { id: 'status', value: statusFilter ?? '' },
+ ],
+ [statusFilter]
+ )
+
+ const toggleStatusFilter = (status) =>
+ setStatusFilter((current) => (current === status ? null : status))
+
+ const tableFilter = (
+
+ {/* stat tiles sit two-up on phones, six stacked rows push the table below the fold. mobile-layout-ok */}
+
+ setStatusFilter(null)}
+ />
+
+ {/* mobile-layout-ok */}
+ {GUEST_STATUSES.map(({ status, color, icon }) => (
+
+ toggleStatusFilter(status)}
+ />
+
+ ))}
+
+ )
+
+ const actions = [
+ {
+ label: 'View User',
+ link: '/identity/administration/users/user?userId=[id]',
+ multiPost: false,
+ icon: ,
+ color: 'success',
+ },
+ {
+ label: 'Re-invite Guest',
+ type: 'POST',
+ icon: ,
+ url: '/api/AddGuest',
+ data: { displayName: 'displayName', mail: 'mail', sendInvite: '!true' },
+ confirmText: 'Are you sure you want to re-send the invitation to [mail]?',
+ multiPost: false,
+ condition: (row) =>
+ !!row.mail &&
+ (row.status === 'Pending Acceptance' || row.status === 'Stale'),
+ },
+ ]
+
+ const offCanvas = {
+ extendedInfoFields: [
+ 'displayName',
+ 'userPrincipalName',
+ 'mail',
+ 'id',
+ 'status',
+ 'externalUserState',
+ 'externalUserStateChangeDateTime',
+ 'createdDateTime',
+ 'lastSignInDateTime',
+ 'lastInteractiveSignInDateTime',
+ 'lastNonInteractiveSignInDateTime',
+ 'lastSuccessfulSignInDateTime',
+ 'daysSinceSignIn',
+ 'accountEnabled',
+ 'sourceDomain',
+ 'sponsors',
+ ],
+ actions: actions,
+ }
+
+ const simpleColumns = [
+ ...reportDB.cacheColumns,
+ 'displayName',
+ 'mail',
+ 'sourceDomain',
+ 'status',
+ 'accountEnabled',
+ 'createdDateTime',
+ 'lastSignInDateTime',
+ 'daysSinceSignIn',
+ ]
+
+ return (
+ <>
+
+ {reportDB.syncDialog}
+ >
+ )
+}
+
+Page.getLayout = (page) => (
+ {page}
+)
+
+export default Page
diff --git a/src/pages/identity/administration/users/user/bec.jsx b/src/pages/identity/administration/users/user/bec.jsx
index d63a7fcac20f..af143ece582b 100644
--- a/src/pages/identity/administration/users/user/bec.jsx
+++ b/src/pages/identity/administration/users/user/bec.jsx
@@ -7,6 +7,7 @@ import CalendarIcon from '@heroicons/react/24/outline/CalendarIcon'
import { Download, Mail, Fingerprint, Launch } from '@mui/icons-material'
import { HeaderedTabbedLayout } from '../../../../../layouts/HeaderedTabbedLayout'
import tabOptions from './tabOptions'
+import { CippUserSwitcher } from '../../../../../components/CippComponents/CippUserSwitcher'
import ReactTimeAgo from 'react-time-ago'
import { CippCopyToClipBoard } from '../../../../../components/CippComponents/CippCopyToClipboard'
import { Box, Stack } from '@mui/system'
@@ -106,17 +107,26 @@ const Page = () => {
}
}
- if (becPollingCall.isSuccess && becPollingCall.data && !becPollingCall.data?.Waiting) {
+ // The !restart guard keeps a refresh from being cancelled: between clicking Refresh Data
+ // and the overwrite call resolving, the polling cache still holds the previous run, which
+ // would otherwise read as "done" and stop the loading state.
+ if (!restart && becPollingCall.isSuccess && becPollingCall.data && !becPollingCall.data?.Waiting) {
setIsLoading(false)
}
}, [becPollingCall.dataUpdatedAt, becInitialCall])
const restartProcess = () => {
setRestart(true)
- becPollingCall.refetch()
+ setIsLoading(true)
+ // The 500ms lets the re-render register Overwrite on the initial call's params. Poll only
+ // after the initial call resolves: the backend resets the cache row to Waiting before it
+ // responds, so a poll issued after that cannot race the reset and resurface the old run.
setTimeout(() => {
- becInitialCall.refetch()
- becPollingCall.refetch()
+ becInitialCall.refetch().finally(() => {
+ // one-shot: without this every later refetch would force a fresh run
+ setRestart(false)
+ becPollingCall.refetch()
+ })
}, 500)
}
@@ -150,21 +160,23 @@ const Page = () => {
const getUserMessage = () => {
if (!becPollingCall.data) return null
if (becPollingCall.data.NewUsers && becPollingCall.data.NewUsers.length > 0) {
- return 'New users have been found in the last 14 days. Please review the list below and take action as needed.'
+ return 'New users have been found in the last 7 days. Please review the list below and take action as needed.'
}
return 'No new users found.'
}
const getAppMessage = () => {
if (!becPollingCall.data) return null
+ const maliciousAddedCount = (becPollingCall.data.AddedApps || []).filter(
+ (app) => app?.MaliciousMatch
+ ).length
+ const maliciousPresentCount = becPollingCall.data.MaliciousSPs?.length || 0
+ if (maliciousAddedCount > 0 || maliciousPresentCount > 0) {
+ return `Potential Breach found: ${
+ maliciousAddedCount + maliciousPresentCount
+ } application(s) in this tenant match the CIPP known-malicious application catalog. Consent-based access survives a password reset, so remove these applications unless their presence is explained.`
+ }
if (becPollingCall.data.AddedApps && becPollingCall.data.AddedApps.length > 0) {
- // Example condition to check for potential breach
- const hasPotentialBreach = becPollingCall.data.AddedApps.some(
- (app) => /* your condition here */ false
- )
- if (hasPotentialBreach) {
- return 'Potential Breach found.'
- }
return 'New applications have been found. Please review the list below and take action as needed.'
}
return 'No new applications found.'
@@ -172,11 +184,13 @@ const Page = () => {
const getMailboxPermissionMessage = () => {
if (!becPollingCall.data) return null
- if (
- becPollingCall.data.MailboxPermissionChanges &&
- becPollingCall.data.MailboxPermissionChanges.length > 0
- ) {
- return 'Mailbox permission changes have been found.'
+ const changes = becPollingCall.data.MailboxPermissionChanges || []
+ if (changes.length > 0) {
+ const targeting = changes.filter((c) => c?.TargetsSuspect === true).length
+ if (targeting > 0) {
+ return `${changes.length} mailbox permission change(s) found across the tenant in the last 7 days, ${targeting} of which target this mailbox. Review those first.`
+ }
+ return `${changes.length} mailbox permission change(s) found across the tenant in the last 7 days. None appear to target this mailbox, but verify the list below.`
}
return 'No mailbox permission changes found.'
}
@@ -184,13 +198,38 @@ const Page = () => {
const getSentMessagesMessage = () => {
if (!becPollingCall.data) return null
if (becPollingCall.data.SentMessages && becPollingCall.data.SentMessages.length > 0) {
- return 'Sent messages have been found. Please review the list below for any suspicious activity.'
+ const analysis = becPollingCall.data.SentMessageAnalysis
+ const parts = [
+ `${analysis?.TotalMessages ?? becPollingCall.data.SentMessages.length} message(s) to ${
+ analysis?.TotalRecipients ?? becPollingCall.data.SentMessages.length
+ } recipient(s) were sent in the last 7 days`,
+ ]
+ if (analysis?.FlaggedSubjectCount > 0) {
+ parts.push(
+ `${analysis.FlaggedSubjectCount} subject(s) were sent as many separate messages or to many recipients — identical-subject mass mail is a classic sign of a compromised mailbox running a campaign`
+ )
+ }
+ if (analysis?.Bursts?.length > 0) {
+ parts.push(
+ `${analysis.Bursts.length} short burst(s) of high-volume sending were detected`
+ )
+ }
+ const foreignCount = becPollingCall.data.LocationAnalysis?.ForeignSentMessageCount || 0
+ if (foreignCount > 0) {
+ parts.push(
+ `${foreignCount} message(s) were sent from an IP outside the user's assigned usage location`
+ )
+ }
+ return `${parts.join('. ')}. Please review the list below for any suspicious activity.`
}
return 'No sent messages found in the specified time range.'
}
const getSafelistMessage = () => {
if (!becPollingCall.data) return null
+ if (becPollingCall.data.SafelistError) {
+ return `${becPollingCall.data.SafelistError} An empty list here is not proof the mailbox has none — refresh after fixing the underlying problem.`
+ }
const trustedCount = becPollingCall.data.TrustedSenders?.length || 0
const blockedCount = becPollingCall.data.BlockedSenders?.length || 0
const changeCount = becPollingCall.data.SafelistChanges?.length || 0
@@ -217,7 +256,9 @@ const Page = () => {
[becPollingCall.data]
)
- const intuneDevicesWindowStart = useMemo(() => {
+ // the analysis window: 7 days before the data was extracted. Shared by the Intune
+ // enrollment and MFA registration recency checks.
+ const analysisWindowStart = useMemo(() => {
const extractedAt = becPollingCall.data?.ExtractedAt
? new Date(becPollingCall.data.ExtractedAt)
: new Date()
@@ -227,6 +268,29 @@ const Page = () => {
return new Date(extractedAt.getTime() - 7 * 24 * 60 * 60 * 1000)
}, [becPollingCall.data?.ExtractedAt])
+ const recentMfaDeviceCount = useMemo(
+ () =>
+ (becPollingCall.data?.MFADevices || []).filter((method) => {
+ if (!method?.createdDateTime) return false
+ const created = new Date(method.createdDateTime)
+ if (Number.isNaN(created.getTime())) return false
+ return created >= analysisWindowStart
+ }).length,
+ [becPollingCall.data?.MFADevices, analysisWindowStart]
+ )
+
+ const foreignActivityCount = useMemo(() => {
+ const analysis = becPollingCall.data?.LocationAnalysis
+ if (!analysis) return 0
+ return (
+ (analysis.ForeignSignInCount || 0) +
+ (analysis.ForeignRuleChangeCount || 0) +
+ (analysis.ForeignSafelistChangeCount || 0) +
+ (analysis.ForeignSharingChangeCount || 0) +
+ (analysis.ForeignSentMessageCount || 0)
+ )
+ }, [becPollingCall.data?.LocationAnalysis])
+
const intuneDevices = useMemo(() => {
const devices = [...(becPollingCall.data?.IntuneDevices || [])]
devices.sort((a, b) => {
@@ -243,9 +307,9 @@ const Page = () => {
if (!device?.enrolledDateTime) return false
const enrolled = new Date(device.enrolledDateTime)
if (Number.isNaN(enrolled.getTime())) return false
- return enrolled >= intuneDevicesWindowStart
+ return enrolled >= analysisWindowStart
}).length,
- [intuneDevices, intuneDevicesWindowStart]
+ [intuneDevices, analysisWindowStart]
)
const intuneDeviceActions = useMemo(
@@ -253,6 +317,91 @@ const Page = () => {
[userSettingsDefaults.currentTenant]
)
+ const getMfaMessage = () => {
+ if (!becPollingCall.data) return null
+ const count = becPollingCall.data.MFADevices?.length || 0
+ if (count === 0) {
+ return 'No MFA methods are registered for this user. If MFA was expected, an attacker may have removed it; either way the account currently has no second factor.'
+ }
+ if (recentMfaDeviceCount > 0) {
+ return `${count} MFA method(s) registered, ${recentMfaDeviceCount} in the last 7 days. Verify the recent registrations were made by the user — attackers register their own method to keep access after a password reset.`
+ }
+ return `${count} MFA method(s) registered. Please review the list below and take action as required.`
+ }
+
+ const getSignInLocationMessage = () => {
+ if (!becPollingCall.data) return null
+ if (becPollingCall.data.SuspectUserSignInsError) {
+ return `${becPollingCall.data.SuspectUserSignInsError} This is not proof the user has no sign-ins — fix the underlying permission or licensing problem and refresh.`
+ }
+ const analysis = becPollingCall.data.LocationAnalysis
+ const signInCount = becPollingCall.data.SuspectUserSignIns?.length || 0
+ if (signInCount === 0) {
+ return 'No sign-ins were found for this user in the sign-in logs.'
+ }
+ const countries = (analysis?.SignInCountries || [])
+ .map((c) => `${c.Country} (${c.Count})`)
+ .join(', ')
+ if (!analysis?.UsageLocation) {
+ return `${
+ analysis?.Note ||
+ 'The user has no usage location assigned in Entra ID, so activity cannot be compared against an expected country.'
+ } Sign-in countries seen: ${countries || 'none recorded'}.`
+ }
+ const foreignParts = []
+ if (analysis.ForeignSignInCount > 0) {
+ foreignParts.push(
+ `${analysis.ForeignSignInCount} sign-in(s), of which ${
+ analysis.ForeignSuccessfulSignInCount || 0
+ } succeeded (failed foreign attempts are mostly password-spray noise)`
+ )
+ }
+ if (analysis.ForeignRuleChangeCount > 0) {
+ foreignParts.push(`${analysis.ForeignRuleChangeCount} inbox rule change(s)`)
+ }
+ if (analysis.ForeignSafelistChangeCount > 0) {
+ foreignParts.push(`${analysis.ForeignSafelistChangeCount} safelist change(s)`)
+ }
+ if (analysis.ForeignSharingChangeCount > 0) {
+ foreignParts.push(`${analysis.ForeignSharingChangeCount} sharing change(s)`)
+ }
+ if (analysis.ForeignSentMessageCount > 0) {
+ foreignParts.push(`${analysis.ForeignSentMessageCount} sent message(s)`)
+ }
+ if (foreignParts.length > 0) {
+ return `The user's assigned usage location is ${
+ analysis.UsageLocation
+ }, but activity originated outside it: ${foreignParts.join(
+ ', '
+ )}. Sign-in countries seen: ${countries}. Review the sign-ins below and the flagged rows in the checks above.`
+ }
+ return `All located activity matches the user's assigned usage location (${
+ analysis.UsageLocation
+ }). Sign-in countries seen: ${countries || 'none recorded'}.`
+ }
+
+ const getSharingMessage = () => {
+ if (!becPollingCall.data) return null
+ const changes = becPollingCall.data.SharingChanges || []
+ if (changes.length === 0) {
+ return 'No sharing links were created or changed by this account in the last 7 days.'
+ }
+ const anonymousCount = changes.filter((c) => c?.Operation?.startsWith('AnonymousLink')).length
+ const foreignCount = becPollingCall.data.LocationAnalysis?.ForeignSharingChangeCount || 0
+ const parts = [
+ `${changes.length} OneDrive/SharePoint sharing change(s) found in the last 7 days`,
+ ]
+ if (anonymousCount > 0) {
+ parts.push(`${anonymousCount} involve anonymous links, which anyone with the URL can open`)
+ }
+ if (foreignCount > 0) {
+ parts.push(`${foreignCount} were made from outside the user's usage location`)
+ }
+ return `${parts.join(
+ '. '
+ )}. Attackers share folders to keep pulling data after a password reset — review each link and remove any that are not explained.`
+ }
+
const getIntuneDevicesMessage = () => {
if (!becPollingCall.data) return null
if (becPollingCall.data.IntuneDevicesError) {
@@ -307,6 +456,13 @@ const Page = () => {
+ }
subtitle={subtitle}
isFetching={userRequest.isFetching}
>
@@ -321,7 +477,7 @@ const Page = () => {
>
{/* Remediation Card */}
-
+
{
/>
{/* Check 1 Card with Loading */}
-
+
{
>
{/* Remediation Card */}
-
+
{
/>
{/* All Steps */}
-
+
@@ -425,10 +581,16 @@ const Page = () => {
))}
@@ -465,7 +627,10 @@ const Page = () => {
{/* Check 3: New Applications */}
{getAppMessage()}
@@ -473,17 +638,48 @@ const Page = () => {
{becPollingCall.data?.AddedApps?.length > 0 && (
- {becPollingCall.data.AddedApps.map((app, index) => (
-
- ))}
+ {[...becPollingCall.data.AddedApps]
+ .sort((a, b) => !!b?.MaliciousMatch - !!a?.MaliciousMatch)
+ .map((app, index) => (
+
+ ))}
)}
+ {becPollingCall.data?.MaliciousSPs?.length > 0 && (
+
+
+ Known-malicious applications present in the tenant (any age)
+
+
+
+ {becPollingCall.data.MaliciousSPs.map((app, index) => (
+
+ ))}
+
+
+
+ )}
{/* Check 4: Mailbox permission changes */}
@@ -497,14 +693,20 @@ const Page = () => {
{becPollingCall.data?.MailboxPermissionChanges?.length > 0 && (
- {becPollingCall.data.MailboxPermissionChanges.map((permission, index) => (
-
- ))}
+ {[...becPollingCall.data.MailboxPermissionChanges]
+ .sort((a, b) => (b?.TargetsSuspect === true) - (a?.TargetsSuspect === true))
+ .map((permission, index) => (
+
+ ))}
)}
@@ -518,6 +720,52 @@ const Page = () => {
{getSentMessagesMessage()}
+ {becPollingCall.data?.SentMessageAnalysis?.RepeatedSubjects?.length > 0 && (
+
+
+ Repeated subjects
+
+
+
+ {becPollingCall.data.SentMessageAnalysis.RepeatedSubjects.map(
+ (group, index) => (
+
+ )
+ )}
+
+
+
+ )}
+ {becPollingCall.data?.SentMessageAnalysis?.Bursts?.length > 0 && (
+
+
+ Send bursts
+
+
+
+ {becPollingCall.data.SentMessageAnalysis.Bursts.map((burst, index) => (
+
+ ))}
+
+
+
+ )}
{becPollingCall.data?.SentMessages?.length > 0 && (
{
hideTitle={true}
title="Sent Messages"
data={becPollingCall.data.SentMessages}
- simpleColumns={['Subject', 'RecipientAddress', 'Status', 'Received', 'FromIP']}
+ simpleColumns={[
+ 'Subject',
+ 'RecipientAddress',
+ 'Status',
+ 'Received',
+ 'FromIP',
+ 'Country',
+ ]}
/>
)}
@@ -536,21 +791,34 @@ const Page = () => {
count={becPollingCall.data?.MFADevices?.length || 0}
>
- MFA Devices have been found. Please review the list below and take action as
- required
+ {getMfaMessage()}
{becPollingCall.data?.MFADevices?.length > 0 && (
- {becPollingCall.data.MFADevices.map((permission, index) => (
-
- ))}
+ {[...becPollingCall.data.MFADevices]
+ .sort(
+ (a, b) =>
+ new Date(b?.createdDateTime || 0) - new Date(a?.createdDateTime || 0)
+ )
+ .map((method, index) => {
+ const isRecent =
+ method?.createdDateTime &&
+ new Date(method.createdDateTime) >= analysisWindowStart
+ return (
+
+ )
+ })}
)}
@@ -584,12 +852,18 @@ const Page = () => {
-
+
{getSafelistMessage()}
{senderRows.length > 0 && (
@@ -614,8 +888,16 @@ const Page = () => {
@@ -662,6 +944,71 @@ const Page = () => {
)}
+ {/* Check 10: Sign-in Locations */}
+
+
+ {getSignInLocationMessage()}
+
+ {becPollingCall.data?.SuspectUserSignIns?.length > 0 && (
+
+
+
+ )}
+
+
+ {/* Check 11: Sharing Links */}
+
+
+ {getSharingMessage()}
+
+ {becPollingCall.data?.SharingChanges?.length > 0 && (
+
+
+
+ )}
+
+
{/* Report Data */}
diff --git a/src/pages/identity/administration/users/user/conditional-access.jsx b/src/pages/identity/administration/users/user/conditional-access.jsx
index 8449148b8562..0ef3dfc43811 100644
--- a/src/pages/identity/administration/users/user/conditional-access.jsx
+++ b/src/pages/identity/administration/users/user/conditional-access.jsx
@@ -7,6 +7,7 @@ import CalendarIcon from "@heroicons/react/24/outline/CalendarIcon";
import { Mail, Fingerprint, Launch } from "@mui/icons-material";
import { HeaderedTabbedLayout } from "../../../../../layouts/HeaderedTabbedLayout";
import tabOptions from "./tabOptions";
+import { CippUserSwitcher } from "../../../../../components/CippComponents/CippUserSwitcher";
import ReactTimeAgo from "react-time-ago";
import { CippCopyToClipBoard } from "../../../../../components/CippComponents/CippCopyToClipboard";
import { Box, Stack, Typography, Button } from "@mui/material";
@@ -95,6 +96,13 @@ const Page = () => {
+ }
subtitle={subtitle}
isFetching={userRequest.isLoading}
>
diff --git a/src/pages/identity/administration/users/user/edit.jsx b/src/pages/identity/administration/users/user/edit.jsx
index fac7c794366a..f4d7fbf0ad2a 100644
--- a/src/pages/identity/administration/users/user/edit.jsx
+++ b/src/pages/identity/administration/users/user/edit.jsx
@@ -12,6 +12,7 @@ import CalendarIcon from '@heroicons/react/24/outline/CalendarIcon'
import { Mail, Fingerprint, Launch } from '@mui/icons-material'
import { HeaderedTabbedLayout } from '../../../../../layouts/HeaderedTabbedLayout'
import tabOptions from './tabOptions'
+import { CippUserSwitcher } from '../../../../../components/CippComponents/CippUserSwitcher'
import { CippCopyToClipBoard } from '../../../../../components/CippComponents/CippCopyToClipboard'
import { CippTimeAgo } from '../../../../../components/CippComponents/CippTimeAgo'
import { Button, Alert } from '@mui/material'
@@ -155,6 +156,13 @@ const Page = () => {
+ }
subtitle={subtitle}
isFetching={userRequest.isLoading}
>
diff --git a/src/pages/identity/administration/users/user/exchange.jsx b/src/pages/identity/administration/users/user/exchange.jsx
index a87124466131..ca1b39c65aa6 100644
--- a/src/pages/identity/administration/users/user/exchange.jsx
+++ b/src/pages/identity/administration/users/user/exchange.jsx
@@ -20,6 +20,7 @@ import {
} from '@mui/icons-material'
import { HeaderedTabbedLayout } from '../../../../../layouts/HeaderedTabbedLayout'
import tabOptions from './tabOptions'
+import { CippUserSwitcher } from '../../../../../components/CippComponents/CippUserSwitcher'
import { CippTimeAgo } from '../../../../../components/CippComponents/CippTimeAgo'
import { CippCopyToClipBoard } from '../../../../../components/CippComponents/CippCopyToClipboard'
import { Box, Stack } from '@mui/system'
@@ -801,26 +802,16 @@ const Page = () => {
icon: ,
url: '/api/ExecModifyCalPerms',
customDataformatter: (row, action, formData) => {
- var permissions = []
- if (Array.isArray(row)) {
- row.forEach((item) => {
- const originalUser = item._raw ? item._raw.User : item.User
- permissions.push({
- UserID: originalUser, // Use original identifier for API calls
- PermissionLevel: item.AccessRights,
- FolderName: item.FolderName,
- Modification: 'Remove',
- })
- })
- } else {
- const originalUser = row._raw ? row._raw.User : row.User
- permissions.push({
- UserID: originalUser, // Use original identifier for API calls
- PermissionLevel: row.AccessRights,
- FolderName: row.FolderName,
- Modification: 'Remove',
- })
- }
+ const rows = Array.isArray(row) ? row : [row]
+ // UserId is the resolved recipient; User is only a display
+ // name, which Exchange cannot resolve when two share it.
+ const permissions = rows.map((item) => ({
+ UserID: item._raw?.UserId || item._raw?.User || item.User,
+ DisplayName: item._raw?.User || item.User,
+ PermissionLevel: item.AccessRights,
+ FolderName: item.FolderName,
+ Modification: 'Remove',
+ }))
return {
userID: graphUserRequest.data?.[0]?.userPrincipalName,
tenantFilter: userSettingsDefaults.currentTenant,
@@ -870,7 +861,8 @@ const Page = () => {
tenantFilter: userSettingsDefaults.currentTenant,
permissions: [
{
- UserID: originalUser, // Use original identifier for API calls
+ UserID: data._raw?.UserId || originalUser,
+ DisplayName: originalUser,
PermissionLevel: data.AccessRights,
FolderName: data.FolderName,
Modification: 'Remove',
@@ -944,26 +936,16 @@ const Page = () => {
icon: ,
url: '/api/ExecModifyContactPerms',
customDataformatter: (row, action, formData) => {
- var permissions = []
- if (Array.isArray(row)) {
- row.forEach((item) => {
- const originalUser = item._raw ? item._raw.User : item.User
- permissions.push({
- UserID: originalUser, // Use original identifier for API calls
- PermissionLevel: item.AccessRights,
- FolderName: item.FolderName,
- Modification: 'Remove',
- })
- })
- } else {
- const originalUser = row._raw ? row._raw.User : row.User
- permissions.push({
- UserID: originalUser, // Use original identifier for API calls
- PermissionLevel: row.AccessRights,
- FolderName: row.FolderName,
- Modification: 'Remove',
- })
- }
+ const rows = Array.isArray(row) ? row : [row]
+ // UserId is the resolved recipient; User is only a display
+ // name, which Exchange cannot resolve when two share it.
+ const permissions = rows.map((item) => ({
+ UserID: item._raw?.UserId || item._raw?.User || item.User,
+ DisplayName: item._raw?.User || item.User,
+ PermissionLevel: item.AccessRights,
+ FolderName: item.FolderName,
+ Modification: 'Remove',
+ }))
return {
userID: graphUserRequest.data?.[0]?.userPrincipalName,
tenantFilter: userSettingsDefaults.currentTenant,
@@ -1013,7 +995,8 @@ const Page = () => {
tenantFilter: userSettingsDefaults.currentTenant,
permissions: [
{
- UserID: originalUser, // Use original identifier for API calls
+ UserID: data._raw?.UserId || originalUser,
+ DisplayName: originalUser,
PermissionLevel: data.AccessRights,
FolderName: data.FolderName,
Modification: 'Remove',
@@ -1425,6 +1408,13 @@ const Page = () => {
+ }
subtitle={subtitle}
actions={CippExchangeActions()}
actionsData={userRequest.data?.[0]?.MailboxActionsData}
@@ -1469,7 +1459,9 @@ const Page = () => {
'Microsoft.Exchange.Configuration.Tasks.ManagementObjectNotFoundException'
) && (
<>
-
+ {/* Stacked below lg — a 4/8 split at phone widths leaves both columns too
+ narrow to hold a label, breaking the text one word per line. */}
+
{
handleRefresh={() => userRequest.refetch()}
/>
-
+
{
<>
Location
-
+
{
]}
/>
-
+
{
+ }
actions={userActions}
actionsData={data}
subtitle={subtitle}
isFetching={userRequest.isLoading}
>
- {userRequest.isLoading && }
+ {/* The loading state is the loaded page's own scaffold with each card in its
+ skeleton form — generic form-row bars looked nothing like what replaces them
+ and left the rest of the viewport empty. */}
+ {userRequest.isLoading && (
+
+
+
+
+
+
+
+ {['Latest Logon', 'Applied Conditional Access Policies', 'Multi-Factor Authentication Devices', 'Memberships'].map(
+ (section) => (
+
+ {section}
+
+
+ )
+ )}
+
+
+
+
+ )}
{userRequest.isSuccess && (
-
+ {/* Stacked below lg — at phone widths a 4/8 split leaves both columns too
+ narrow to hold a label, breaking the text one word per line. */}
+
-
+
Latest Logon
{
value: [{ id: "Name", value: "CA Exclusion" }],
type: "column",
},
+ {
+ filterName: "Location Alerts",
+ value: [{ id: "Name", value: "Location Alert Exclusion" }],
+ type: "column",
+ },
{
filterName: "Mailbox Permissions",
value: [{ id: "Name", value: "Mailbox Vacation" }],
diff --git a/src/pages/identity/reports/inactive-users-report/index.js b/src/pages/identity/reports/inactive-users-report/index.js
index 8e8e7a0edc50..e04dc5d1dd7c 100644
--- a/src/pages/identity/reports/inactive-users-report/index.js
+++ b/src/pages/identity/reports/inactive-users-report/index.js
@@ -20,14 +20,14 @@ const Page = () => {
const actions = [
{
label: "View User",
- link: "/identity/administration/users/user?userId=[azureAdUserId]",
+ link: "/identity/administration/users/user?userId=[azureAdUserId]&tenantFilter=[tenantId]",
multiPost: false,
icon: ,
color: "success",
},
{
label: "Edit User",
- link: "/identity/administration/users/user/edit?userId=[azureAdUserId]",
+ link: "/identity/administration/users/user/edit?userId=[azureAdUserId]&tenantFilter=[tenantId]",
icon: ,
color: "success",
target: "_self",
@@ -61,6 +61,7 @@ const Page = () => {
"createdDateTime",
"lastSignInDateTime",
"lastNonInteractiveSignInDateTime",
+ "lastSuccessfulSignInDateTime",
"numberOfAssignedLicenses",
"daysSinceLastSignIn",
"lastRefreshedDateTime",
@@ -75,6 +76,7 @@ const Page = () => {
"displayName",
"lastSignInDateTime",
"lastNonInteractiveSignInDateTime",
+ "lastSuccessfulSignInDateTime",
"numberOfAssignedLicenses",
"daysSinceLastSignIn",
...reportDB.cacheColumns.filter((c) => c !== "Tenant"),
@@ -89,7 +91,7 @@ const Page = () => {
actions={actions}
offCanvas={offCanvas}
simpleColumns={simpleColumns}
- cardButton={reportDB.controls}
+ dataSourceControls={reportDB.controls}
/>
{reportDB.syncDialog}
>
diff --git a/src/pages/identity/reports/mfa-report/index.js b/src/pages/identity/reports/mfa-report/index.js
index 668030f9f923..efe9e34a5c04 100644
--- a/src/pages/identity/reports/mfa-report/index.js
+++ b/src/pages/identity/reports/mfa-report/index.js
@@ -117,7 +117,7 @@ const Page = () => {
simpleColumns={simpleColumns}
filters={filters}
actions={actions}
- cardButton={reportDB.controls}
+ dataSourceControls={reportDB.controls}
initialFilters={urlFilters}
/>
{reportDB.syncDialog}
diff --git a/src/pages/identity/reports/signin-report/index.js b/src/pages/identity/reports/signin-report/index.js
index c835fa6ccf4b..c21dda6804f0 100644
--- a/src/pages/identity/reports/signin-report/index.js
+++ b/src/pages/identity/reports/signin-report/index.js
@@ -242,7 +242,7 @@ const Page = () => {
/>
-
+
{
/>
-
+
Apply
diff --git a/src/pages/security/reports/cve-report/index.js b/src/pages/security/reports/cve-report/index.js
index b0d8317374d8..1c721955990e 100644
--- a/src/pages/security/reports/cve-report/index.js
+++ b/src/pages/security/reports/cve-report/index.js
@@ -18,7 +18,7 @@ const Page = () => {
"exceptionType",
"exceptionComment",
"exceptionCreatedBy",
- "exceptionReadableDate",
+ "exceptionDate",
"exceptionExpiry",
]}
/>
diff --git a/src/pages/security/reports/mde-onboarding/index.js b/src/pages/security/reports/mde-onboarding/index.js
index 955ec17fa66a..aa88b8c7516a 100644
--- a/src/pages/security/reports/mde-onboarding/index.js
+++ b/src/pages/security/reports/mde-onboarding/index.js
@@ -355,7 +355,7 @@ const Page = () => {
"partnerUnresponsivenessThresholdInDays",
"CacheTimestamp",
]}
- cardButton={reportDB.controls}
+ dataSourceControls={reportDB.controls}
/>
{reportDB.syncDialog}
>
diff --git a/src/pages/security/safelinks/safelinks/index.jsx b/src/pages/security/safelinks/safelinks/index.jsx
index 02ccc9f872ea..bbc08e2a0b75 100644
--- a/src/pages/security/safelinks/safelinks/index.jsx
+++ b/src/pages/security/safelinks/safelinks/index.jsx
@@ -21,6 +21,19 @@ const Page = () => {
}
];
+ // Rows for orphaned built-in EOP rules carry PolicyName = null, so every condition has to
+ // tolerate a missing name rather than dereferencing it. A row with no policy behind it is
+ // Microsoft managed for these purposes, which is what the string comparisons already encode.
+ const isMicrosoftManaged = (row) => {
+ const name = row?.PolicyName ?? "";
+ return (
+ row?.IsBuiltIn === true ||
+ name.startsWith("Standard Preset Security Policy") ||
+ name.startsWith("Strict Preset Security Policy") ||
+ name === "Built-In Protection Policy"
+ );
+ };
+
const actions = [
{
label: "Edit Safe Links Policy",
@@ -28,7 +41,7 @@ const Page = () => {
icon: ,
color: "success",
target: "_self",
- condition: (row) => !row.IsBuiltInProtection && !row.PolicyName.startsWith("Standard Preset Security Policy") && !row.PolicyName.startsWith("Strict Preset Security Policy") && row.PolicyName !== "Built-In Protection Policy",
+ condition: (row) => !isMicrosoftManaged(row),
},
{
label: "Enable Rule",
@@ -42,7 +55,7 @@ const Page = () => {
},
confirmText: "Are you sure you want to enable this rule?",
color: "info",
- condition: (row) => row.State === "Disabled" && !row.IsBuiltInProtection && !row.PolicyName.startsWith("Standard Preset Security Policy") && !row.PolicyName.startsWith("Strict Preset Security Policy")&& row.PolicyName !== "Built-In Protection Policy",
+ condition: (row) => row.State === "Disabled" && !isMicrosoftManaged(row),
},
{
label: "Disable Rule",
@@ -56,14 +69,14 @@ const Page = () => {
},
confirmText: "Are you sure you want to disable this rule?",
color: "info",
- condition: (row) => row.State === "Enabled" && !row.IsBuiltInProtection && !row.PolicyName.startsWith("Standard Preset Security Policy") && !row.PolicyName.startsWith("Strict Preset Security Policy")&& row.PolicyName !== "Built-In Protection Policy",
+ condition: (row) => row.State === "Enabled" && !isMicrosoftManaged(row),
},
{
label: "Set Priority",
type: "POST",
icon: ,
url: "/api/EditSafeLinksPolicy",
- condition: (row) => !row.IsBuiltInProtection && !row.PolicyName.startsWith("Standard Preset Security Policy") && !row.PolicyName.startsWith("Strict Preset Security Policy")&& row.PolicyName !== "Built-In Protection Policy",
+ condition: (row) => !isMicrosoftManaged(row),
data: {
PolicyName: "PolicyName",
Name: "PolicyName"
@@ -95,7 +108,7 @@ const Page = () => {
confirmText: "Are you sure you want to create a template based on this policy?",
icon: ,
hideBulk: true,
- condition: (row) => !row.IsBuiltInProtection && !row.PolicyName.startsWith("Standard Preset Security Policy") && !row.PolicyName.startsWith("Strict Preset Security Policy")&& row.PolicyName !== "Built-In Protection Policy",
+ condition: (row) => !isMicrosoftManaged(row),
},
{
label: "Delete Rule",
@@ -108,7 +121,7 @@ const Page = () => {
},
confirmText: "Are you sure you want to delete this policy and rule?",
color: "danger",
- condition: (row) => !row.IsBuiltInProtection && !row.PolicyName.startsWith("Standard Preset Security Policy") && !row.PolicyName.startsWith("Strict Preset Security Policy")&& row.PolicyName !== "Built-In Protection Policy",
+ condition: (row) => !isMicrosoftManaged(row),
}
];
diff --git a/src/pages/teams-share/external-users.js b/src/pages/teams-share/external-users.js
index 85fd7a8fa31c..b14abaae836e 100644
--- a/src/pages/teams-share/external-users.js
+++ b/src/pages/teams-share/external-users.js
@@ -22,14 +22,16 @@ const Page = () => {
icon: ,
url: '/api/ExecRemoveSPOExternalUser',
customDataformatter: (row) => {
- const r = Array.isArray(row) ? row[0] : row
- return {
+ const formatRow = (r) => ({
tenantFilter: r.Tenant ?? tenantFilter,
EntraUserId: r.EntraUserId,
LoginName: r.LoginName,
SiteUrls: Array.isArray(r.Sites) ? r.Sites : [],
DisplayName: r.DisplayName,
- }
+ })
+ // When multiple rows are selected, row is an array. Returning an array
+ // makes CippApiDialog send one request per row (bulk request mode).
+ return Array.isArray(row) ? row.map(formatRow) : formatRow(row)
},
confirmText:
'Fully remove guest access for [DisplayName]? This deletes their Entra guest account (if one exists) AND removes them from every site listed in the Sites column, so nothing is left orphaned. Sharing links they hold can be revoked from the Sharing Report; the inert SharePoint store entry ages out on its own.',
diff --git a/src/pages/teams-share/onedrive/index.js b/src/pages/teams-share/onedrive/index.js
index 9b4f9029c08d..4ee47b84c55a 100644
--- a/src/pages/teams-share/onedrive/index.js
+++ b/src/pages/teams-share/onedrive/index.js
@@ -1,10 +1,16 @@
import { Layout as DashboardLayout } from '../../../layouts/index.js'
import { CippTablePage } from '../../../components/CippComponents/CippTablePage.jsx'
-import { PersonAdd, PersonRemove } from '@mui/icons-material'
+import { PersonAdd, PersonRemove, Settings } from '@mui/icons-material'
import { useCippReportDB } from '../../../components/CippComponents/CippReportDBControls'
+import { useSettings } from '../../../hooks/use-settings'
+import { usePermissions } from '../../../hooks/use-permissions'
+import { CippEditSitePropertiesForm } from '../../../components/CippComponents/CippEditSitePropertiesForm'
const Page = () => {
const pageTitle = 'OneDrive'
+ const tenantFilter = useSettings().currentTenant
+ const { checkPermissions } = usePermissions()
+ const canWriteSite = checkPermissions(['Sharepoint.Site.ReadWrite'])
const reportDB = useCippReportDB({
apiUrl: '/api/ListSites?type=OneDriveUsageAccount',
queryKey: 'ListSites-OneDriveUsageAccount',
@@ -94,6 +100,67 @@ const Page = () => {
},
],
},
+ {
+ label: 'Edit OneDrive Site',
+ type: 'POST',
+ icon: ,
+ url: '/api/ExecSetSiteProperties',
+ confirmText:
+ 'Edit OneDrive site properties for [displayName]. Fields are prefilled with the current values.',
+ condition: () => canWriteSite,
+ children: ({ formHook, row }) => (
+
+ ),
+ customDataformatter: (row, action, formData) => {
+ const v = (x) => (x && typeof x === 'object' && 'value' in x ? x.value : x)
+ // OneDrive sites are never group-connected, so the full personal-site property set
+ // applies to every selected row.
+ const formatRow = (siteRow) => {
+ const payload = {
+ tenantFilter: siteRow.Tenant ?? tenantFilter,
+ SiteUrl: siteRow.webUrl,
+ Title: formData.Title,
+ SharingCapability: v(formData.SharingCapability),
+ DefaultSharingLinkType: v(formData.DefaultSharingLinkType),
+ DefaultLinkPermission: v(formData.DefaultLinkPermission),
+ SharingDomainRestrictionMode: v(formData.SharingDomainRestrictionMode),
+ OverrideTenantAnonymousLinkExpirationPolicy:
+ !!formData.OverrideTenantAnonymousLinkExpirationPolicy,
+ InheritVersionPolicyFromTenant: !!formData.InheritVersionPolicyFromTenant,
+ LockState: v(formData.LockState),
+ }
+ if (v(formData.SharingDomainRestrictionMode) === 'AllowList') {
+ payload.SharingAllowedDomainList = formData.SharingAllowedDomainList
+ }
+ if (v(formData.SharingDomainRestrictionMode) === 'BlockList') {
+ payload.SharingBlockedDomainList = formData.SharingBlockedDomainList
+ }
+ if (formData.OverrideTenantAnonymousLinkExpirationPolicy) {
+ payload.AnonymousLinkExpirationInDays = parseInt(
+ formData.AnonymousLinkExpirationInDays ?? 0,
+ 10
+ )
+ }
+ const storageMax = parseInt(formData.StorageMaximumLevel, 10)
+ const storageWarn = parseInt(formData.StorageWarningLevel, 10)
+ if (!isNaN(storageMax) && storageMax > 0) payload.StorageMaximumLevel = storageMax
+ if (!isNaN(storageWarn) && storageWarn > 0) payload.StorageWarningLevel = storageWarn
+ if (!formData.InheritVersionPolicyFromTenant) {
+ payload.EnableAutoExpirationVersionTrim = !!formData.EnableAutoExpirationVersionTrim
+ if (!formData.EnableAutoExpirationVersionTrim) {
+ payload.MajorVersionLimit = parseInt(formData.MajorVersionLimit ?? 0, 10)
+ payload.ExpireVersionsAfterDays = parseInt(formData.ExpireVersionsAfterDays ?? 0, 10)
+ }
+ }
+ return payload
+ }
+ // When multiple rows are selected, row is an array. Returning an array
+ // makes CippApiDialog send one request per row (bulk request mode).
+ return Array.isArray(row) ? row.map(formatRow) : formatRow(row)
+ },
+ multiPost: false,
+ allowResubmit: true,
+ },
]
const simpleColumns = [
@@ -119,7 +186,7 @@ const Page = () => {
queryKey={reportDB.resolvedQueryKey}
actions={actions}
simpleColumns={simpleColumns}
- cardButton={reportDB.controls}
+ dataSourceControls={reportDB.controls}
/>
{reportDB.syncDialog}
>
diff --git a/src/pages/teams-share/permissions-report/index.js b/src/pages/teams-share/permissions-report/index.js
index 61d24b785e0e..12dcbd1c913b 100644
--- a/src/pages/teams-share/permissions-report/index.js
+++ b/src/pages/teams-share/permissions-report/index.js
@@ -13,6 +13,7 @@ import { ApiGetCall } from '../../../api/ApiCall'
import { useSettings } from '../../../hooks/use-settings'
import { Alert, Button, Container, Stack, SvgIcon, Typography } from '@mui/material'
import { Grid } from '@mui/system'
+import { CippExpandableAlert } from '../../../components/CippComponents/CippExpandableAlert'
import {
BuildingOfficeIcon,
CloudArrowDownIcon,
@@ -253,7 +254,7 @@ const Page = () => {
)}
-
+
Applies To shows how far each permission reaches.{' '}
Whole site is a permission on the site itself, which every library that
still inherits also gets. This library only means that library was detached
@@ -261,7 +262,7 @@ const Page = () => {
that still inherit are not listed — their permissions are the site's, so
everything here is either the site's own permissions or a deliberate exception
to them.
-
+
{
allowAllTenantSync: true,
})
+ // Two different faults produce empty usage columns here, and they need different advice.
+ //
+ // Anonymization: Microsoft 365 hashes the owner names in the SharePoint site usage report.
+ // Only hashed values prove this - absent usage data does not, because anonymization still
+ // returns rows, it just hashes them. Both the live and cached paths merge the same report,
+ // so this is not gated on cache mode.
+ const anonymizedReport = useReportAnonymized({
+ url: reportDB.resolvedApiUrl,
+ data: reportDB.resolvedApiData,
+ queryKey: reportDB.resolvedQueryKey,
+ check: (rows) => isReportAnonymized(rows, ['ownerPrincipalName', 'ownerDisplayName']),
+ })
+
+ // Empty usage report: getSharePointSiteUsageDetail returns no rows at all for tenants
+ // Microsoft has not generated a report for yet. The site listing still populates the table,
+ // so every usage-derived column is blank. reportRefreshDate comes only from that report, so
+ // an empty one across every row means the merge contributed nothing.
+ const noUsageData = useReportAnonymized({
+ url: reportDB.resolvedApiUrl,
+ data: reportDB.resolvedApiData,
+ queryKey: reportDB.resolvedQueryKey,
+ check: (rows) => rows.every((site) => !site?.reportRefreshDate),
+ })
+
const actions = [
{
label: 'Add Member',
@@ -357,48 +387,54 @@ const Page = () => {
),
customDataformatter: (row, action, formData) => {
- const siteRow = Array.isArray(row) ? row[0] : row
- const isGroupSite = siteRow?.rootWebTemplate === 'Group'
const v = (x) => (x && typeof x === 'object' && 'value' in x ? x.value : x)
- const payload = {
- tenantFilter: siteRow.Tenant ?? tenantFilter,
- SiteUrl: siteRow.webUrl,
- SharingCapability: v(formData.SharingCapability),
- DefaultSharingLinkType: v(formData.DefaultSharingLinkType),
- DefaultLinkPermission: v(formData.DefaultLinkPermission),
- LockState: v(formData.LockState),
- }
- if (!isGroupSite) {
- payload.Title = formData.Title
- payload.SharingDomainRestrictionMode = v(formData.SharingDomainRestrictionMode)
- payload.OverrideTenantAnonymousLinkExpirationPolicy =
- !!formData.OverrideTenantAnonymousLinkExpirationPolicy
- payload.InheritVersionPolicyFromTenant = !!formData.InheritVersionPolicyFromTenant
- }
- if (!isGroupSite && v(formData.SharingDomainRestrictionMode) === 'AllowList') {
- payload.SharingAllowedDomainList = formData.SharingAllowedDomainList
- }
- if (!isGroupSite && v(formData.SharingDomainRestrictionMode) === 'BlockList') {
- payload.SharingBlockedDomainList = formData.SharingBlockedDomainList
- }
- if (!isGroupSite && formData.OverrideTenantAnonymousLinkExpirationPolicy) {
- payload.AnonymousLinkExpirationInDays = parseInt(
- formData.AnonymousLinkExpirationInDays ?? 0,
- 10
- )
- }
- const storageMax = parseInt(formData.StorageMaximumLevel, 10)
- const storageWarn = parseInt(formData.StorageWarningLevel, 10)
- if (!isNaN(storageMax) && storageMax > 0) payload.StorageMaximumLevel = storageMax
- if (!isNaN(storageWarn) && storageWarn > 0) payload.StorageWarningLevel = storageWarn
- if (!isGroupSite && !formData.InheritVersionPolicyFromTenant) {
- payload.EnableAutoExpirationVersionTrim = !!formData.EnableAutoExpirationVersionTrim
- if (!formData.EnableAutoExpirationVersionTrim) {
- payload.MajorVersionLimit = parseInt(formData.MajorVersionLimit ?? 0, 10)
- payload.ExpireVersionsAfterDays = parseInt(formData.ExpireVersionsAfterDays ?? 0, 10)
+ // isGroupSite is evaluated per site: a selection can mix group-backed and classic
+ // sites, and the group-backed ones reject the properties guarded below.
+ const formatRow = (siteRow) => {
+ const isGroupSite = siteRow?.rootWebTemplate === 'Group'
+ const payload = {
+ tenantFilter: siteRow.Tenant ?? tenantFilter,
+ SiteUrl: siteRow.webUrl,
+ SharingCapability: v(formData.SharingCapability),
+ DefaultSharingLinkType: v(formData.DefaultSharingLinkType),
+ DefaultLinkPermission: v(formData.DefaultLinkPermission),
+ LockState: v(formData.LockState),
+ }
+ if (!isGroupSite) {
+ payload.Title = formData.Title
+ payload.SharingDomainRestrictionMode = v(formData.SharingDomainRestrictionMode)
+ payload.OverrideTenantAnonymousLinkExpirationPolicy =
+ !!formData.OverrideTenantAnonymousLinkExpirationPolicy
+ payload.InheritVersionPolicyFromTenant = !!formData.InheritVersionPolicyFromTenant
}
+ if (!isGroupSite && v(formData.SharingDomainRestrictionMode) === 'AllowList') {
+ payload.SharingAllowedDomainList = formData.SharingAllowedDomainList
+ }
+ if (!isGroupSite && v(formData.SharingDomainRestrictionMode) === 'BlockList') {
+ payload.SharingBlockedDomainList = formData.SharingBlockedDomainList
+ }
+ if (!isGroupSite && formData.OverrideTenantAnonymousLinkExpirationPolicy) {
+ payload.AnonymousLinkExpirationInDays = parseInt(
+ formData.AnonymousLinkExpirationInDays ?? 0,
+ 10
+ )
+ }
+ const storageMax = parseInt(formData.StorageMaximumLevel, 10)
+ const storageWarn = parseInt(formData.StorageWarningLevel, 10)
+ if (!isNaN(storageMax) && storageMax > 0) payload.StorageMaximumLevel = storageMax
+ if (!isNaN(storageWarn) && storageWarn > 0) payload.StorageWarningLevel = storageWarn
+ if (!isGroupSite && !formData.InheritVersionPolicyFromTenant) {
+ payload.EnableAutoExpirationVersionTrim = !!formData.EnableAutoExpirationVersionTrim
+ if (!formData.EnableAutoExpirationVersionTrim) {
+ payload.MajorVersionLimit = parseInt(formData.MajorVersionLimit ?? 0, 10)
+ payload.ExpireVersionsAfterDays = parseInt(formData.ExpireVersionsAfterDays ?? 0, 10)
+ }
+ }
+ return payload
}
- return payload
+ // When multiple rows are selected, row is an array. Returning an array
+ // makes CippApiDialog send one request per row (bulk request mode).
+ return Array.isArray(row) ? row.map(formatRow) : formatRow(row)
},
multiPost: false,
allowResubmit: true,
@@ -514,6 +550,7 @@ const Page = () => {
/>
),
multiPost: false,
+ hideBulk: true,
},
{
label: 'Delete Site',
@@ -647,6 +684,7 @@ const Page = () => {
/>
),
multiPost: false,
+ hideBulk: true,
},
{
label: 'Check Cleanup Job Status',
@@ -661,6 +699,7 @@ const Page = () => {
/>
),
multiPost: false,
+ hideBulk: true,
},
]
@@ -712,7 +751,6 @@ const Page = () => {
>
Bulk Add Sites
- {reportDB.controls}
)
@@ -727,6 +765,23 @@ const Page = () => {
offCanvas={offCanvas}
simpleColumns={simpleColumns}
cardButton={pageActions}
+ dataSourceControls={reportDB.controls}
+ tableFilter={
+ <>
+
+
+ Site owner names in this report are pseudo-anonymised because Microsoft 365 report
+ anonymization is enabled for this tenant.
+
+ {!anonymizedReport && noUsageData && (
+
+ Microsoft returned no SharePoint usage report for this tenant, so activity,
+ storage and file count are blank. The site list itself is complete. Usage reports
+ can take up to 48 hours to appear on a new tenant.
+
+ )}
+ >
+ }
/>
{reportDB.syncDialog}
>
diff --git a/src/pages/teams-share/sharepoint2/index.js b/src/pages/teams-share/sharepoint2/index.js
new file mode 100644
index 000000000000..6020001b4e71
--- /dev/null
+++ b/src/pages/teams-share/sharepoint2/index.js
@@ -0,0 +1,322 @@
+import { useEffect, useMemo, useState } from 'react'
+import { useRouter } from 'next/router'
+import { Container, IconButton, Stack, Tooltip, Typography } from '@mui/material'
+import { Grid } from '@mui/system'
+import { Delete, FolderOpen, Launch, Refresh, Storage as StorageIcon } from '@mui/icons-material'
+import { Layout as DashboardLayout } from '../../../layouts/index.js'
+import { CippHead } from '../../../components/CippComponents/CippHead'
+import { CippSharePointBrowserBanner } from '../../../components/CippComponents/CippSharePointBrowserBanner'
+import { CippSharePointBrowserProperties } from '../../../components/CippComponents/CippSharePointBrowserProperties'
+import { CippSharePointBrowserPermissions } from '../../../components/CippComponents/CippSharePointBrowserPermissions'
+import { CippSharePointBrowserStorage } from '../../../components/CippComponents/CippSharePointBrowserStorage'
+import { CippSharePointFolderView } from '../../../components/CippComponents/CippSharePointFolderView'
+import { ApiGetCall } from '../../../api/ApiCall'
+import { useSettings } from '../../../hooks/use-settings'
+
+const openUrls = (rows) => {
+ const list = Array.isArray(rows) ? rows : [rows]
+ list.forEach((row) => {
+ if (row?.webUrl) {
+ window.open(row.webUrl, '_blank', 'noopener,noreferrer')
+ }
+ })
+}
+
+const queryString = (value) => (typeof value === 'string' && value.length > 0 ? value : null)
+
+const isSiteRow = (row) => row?.type === 'site'
+
+const Page = () => {
+ const router = useRouter()
+ const tenantFilter = useSettings().currentTenant
+ const [checkedIds, setCheckedIds] = useState([])
+ const [permissionsOpen, setPermissionsOpen] = useState(false)
+ const [storageOpen, setStorageOpen] = useState(false)
+
+ // Location is owned by the URL (?siteId=…) — name/url come from navigation or API Site
+ const siteId = queryString(router.query.siteId)
+ const [siteMeta, setSiteMeta] = useState(null)
+
+ const openedSite =
+ router.isReady && siteId
+ ? {
+ id: siteId,
+ webUrl: siteMeta?.id === siteId ? siteMeta.webUrl : undefined,
+ displayName: siteMeta?.id === siteId ? siteMeta.displayName || siteMeta.webUrl : '…',
+ type: 'site',
+ canOpen: true,
+ storageUsedInBytes:
+ siteMeta?.id === siteId ? siteMeta.storageUsedInBytes : undefined,
+ }
+ : null
+ const path = openedSite ? [openedSite] : []
+ const atRoot = !openedSite
+
+ // Browser back/forward changes location without going through handlers
+ useEffect(() => {
+ setCheckedIds([])
+ }, [siteId])
+
+ const setBrowserLocation = (site) => {
+ if (!router.isReady) return
+ const query = { ...router.query }
+ if (site?.id) {
+ query.siteId = site.id
+ setSiteMeta(site)
+ } else {
+ delete query.siteId
+ setSiteMeta(null)
+ }
+ delete query.siteUrl
+ delete query.siteName
+ delete query.siteType
+ router.replace({ pathname: router.pathname, query }, undefined, { shallow: true })
+ }
+
+ const browserApi = ApiGetCall({
+ url: '/api/ListSiteBrowser',
+ data: {
+ tenantFilter,
+ ...(siteId ? { SiteId: siteId } : {}),
+ },
+ queryKey: siteId
+ ? `ListSiteBrowser-${tenantFilter}-${siteId}`
+ : `ListSiteBrowser-${tenantFilter}-root`,
+ waiting: router.isReady && !!tenantFilter && tenantFilter !== 'AllTenants',
+ })
+
+ // Enrich from API after cold load / refresh
+ useEffect(() => {
+ const site = browserApi.data?.Site
+ if (site?.id && site.id === siteId) {
+ setSiteMeta((prev) => ({
+ ...prev,
+ ...site,
+ // Keep storage from the site we opened if the Site payload doesn't include it
+ storageUsedInBytes: site.storageUsedInBytes ?? prev?.storageUsedInBytes,
+ }))
+ }
+ }, [browserApi.data?.Site, siteId])
+
+ const rawResults = browserApi.data?.Results
+ const items = useMemo(() => {
+ if (!Array.isArray(rawResults)) return []
+ return rawResults.map((row) => ({
+ ...row,
+ canOpen: row.type === 'site',
+ }))
+ }, [rawResults])
+
+ const checkedItems = useMemo(() => {
+ if (!checkedIds.length) return []
+ const idSet = new Set(checkedIds)
+ return items.filter((item) => idSet.has(item.id))
+ }, [items, checkedIds])
+
+ // Single checked row drives properties / permissions; multi-check is for Actions only.
+ const selected = checkedItems.length === 1 ? checkedItems[0] : null
+
+ const actionRows = useMemo(() => {
+ if (checkedItems.length) return checkedItems
+ if (openedSite?.webUrl) return [openedSite]
+ return []
+ }, [checkedItems, openedSite])
+
+ const errorMessage =
+ typeof rawResults === 'string'
+ ? rawResults
+ : browserApi.isError
+ ? (browserApi.error?.message ?? 'Failed to load items.')
+ : null
+
+ // Banner always reflects the opened site; library only when one is selected
+ const bannerSite = openedSite ?? (selected?.type === 'site' ? selected : null)
+ const bannerLibrary = selected?.type === 'library' ? selected : null
+ const propertiesItem = selected
+
+ // Storage is site-scoped: selected site at root, or the opened site when drilled in
+ const storageSite = isSiteRow(selected) ? selected : openedSite
+ const showStorage = Boolean(storageSite?.webUrl)
+
+ const handleCheckedChange = (ids) => {
+ setCheckedIds(ids)
+ }
+
+ const handleOpen = (item) => {
+ if (!item?.canOpen) return
+ setCheckedIds([])
+ setBrowserLocation(item)
+ }
+
+ const handleNavigate = (nextPath) => {
+ setCheckedIds([])
+ setBrowserLocation(nextPath?.[0] ?? null)
+ }
+
+ const bulkActions = useMemo(
+ () => [
+ {
+ label: 'Open in SharePoint',
+ icon: ,
+ showInActionsMenu: true,
+ noConfirm: true,
+ customFunction: (rows) => openUrls(rows),
+ condition: (rows) =>
+ (Array.isArray(rows) ? rows : [rows]).some((row) => Boolean(row?.webUrl)),
+ },
+ {
+ label: 'Storage',
+ icon: ,
+ showInActionsMenu: true,
+ noConfirm: true,
+ condition: (rows) => {
+ const list = Array.isArray(rows) ? rows : [rows]
+ return list.length === 1 && isSiteRow(list[0]) && Boolean(list[0]?.webUrl)
+ },
+ customFunction: (rows) => {
+ const list = Array.isArray(rows) ? rows : [rows]
+ if (list[0]?.id) setCheckedIds([list[0].id])
+ setStorageOpen(true)
+ },
+ },
+ {
+ label: 'Delete',
+ icon: ,
+ showInActionsMenu: true,
+ noConfirm: true,
+ customFunction: () => {},
+ },
+ ],
+ []
+ )
+
+ const rowActions = useMemo(
+ () => [
+ {
+ label: 'Open in SharePoint',
+ icon: ,
+ condition: (item) => Boolean(item?.webUrl),
+ href: (item) => item.webUrl,
+ },
+ {
+ label: 'Browse',
+ icon: ,
+ condition: (item) => Boolean(item?.canOpen),
+ onClick: handleOpen,
+ },
+ {
+ label: 'Storage',
+ icon: ,
+ condition: (item) => isSiteRow(item) && Boolean(item?.webUrl),
+ onClick: (item) => {
+ if (item?.id) setCheckedIds([item.id])
+ setStorageOpen(true)
+ },
+ },
+ {
+ label: 'Delete',
+ icon: ,
+ onClick: () => {},
+ },
+ ],
+ []
+ )
+
+ return (
+ <>
+
+
+
+
+ SharePoint Site Browser
+
+
+ browserApi.refetch()}
+ disabled={!tenantFilter || tenantFilter === 'AllTenants' || browserApi.isFetching}
+ >
+
+
+
+
+
+ {!tenantFilter || tenantFilter === 'AllTenants' ? (
+
+ Select a tenant to browse SharePoint sites.
+
+ ) : (
+ <>
+ setStorageOpen(true)}
+ showPermissions={selected?.type === 'site' || selected?.type === 'library'}
+ onPermissionsClick={() => setPermissionsOpen(true)}
+ showEditSite={Boolean(openedSite) || isSiteRow(selected)}
+ queryKeys={
+ siteId
+ ? `ListSiteBrowser-${tenantFilter}-${siteId}`
+ : `ListSiteBrowser-${tenantFilter}-root`
+ }
+ />
+ setPermissionsOpen(false)}
+ item={selected}
+ tenantFilter={tenantFilter}
+ siteUrl={selected?.type === 'library' ? openedSite?.webUrl : selected?.webUrl}
+ siteId={selected?.type === 'library' ? openedSite?.id : selected?.id}
+ />
+ setStorageOpen(false)}
+ item={storageSite}
+ tenantFilter={tenantFilter}
+ />
+
+
+
+
+
+
+
+
+ >
+ )}
+
+
+ >
+ )
+}
+
+Page.getLayout = (page) => {page}
+
+export default Page
diff --git a/src/pages/teams-share/sharing-report/index.js b/src/pages/teams-share/sharing-report/index.js
index 38958272b110..5f28b1969dd8 100644
--- a/src/pages/teams-share/sharing-report/index.js
+++ b/src/pages/teams-share/sharing-report/index.js
@@ -87,7 +87,7 @@ const SharingLinkDetail = ({ row }) => {
{properties
.filter((prop) => prop.value !== undefined && prop.value !== null && prop.value !== '')
.map((prop) => (
-
+
{prop.label}
diff --git a/src/pages/teams-share/teams/business-voice/index.js b/src/pages/teams-share/teams/business-voice/index.js
index 2a600064ca8e..72f80d1dda12 100644
--- a/src/pages/teams-share/teams/business-voice/index.js
+++ b/src/pages/teams-share/teams/business-voice/index.js
@@ -139,7 +139,7 @@ const Page = () => {
"Complex: AssignmentStatus eq Unassigned; AcquiredCapabilities like UserAssignment",
},
]}
- cardButton={reportDB.controls}
+ dataSourceControls={reportDB.controls}
/>
{reportDB.syncDialog}
>
diff --git a/src/pages/teams-share/teams/list-team/index.js b/src/pages/teams-share/teams/list-team/index.js
index 99b51994bafe..82f175d82544 100644
--- a/src/pages/teams-share/teams/list-team/index.js
+++ b/src/pages/teams-share/teams/list-team/index.js
@@ -62,9 +62,9 @@ const Page = () => {
}>
Add Team
- {reportDB.controls}
}
+ dataSourceControls={reportDB.controls}
/>
{reportDB.syncDialog}
>
diff --git a/src/pages/teams-share/teams/teams-activity/index.js b/src/pages/teams-share/teams/teams-activity/index.js
index 69d3fa3ebc72..bc3f825da9cd 100644
--- a/src/pages/teams-share/teams/teams-activity/index.js
+++ b/src/pages/teams-share/teams/teams-activity/index.js
@@ -39,7 +39,7 @@ const Page = () => {
"CallCount",
"TeamsChat",
]}
- cardButton={reportDB.controls}
+ dataSourceControls={reportDB.controls}
/>
{reportDB.syncDialog}
>
diff --git a/src/pages/tenant/administration/add-subscription/index.jsx b/src/pages/tenant/administration/add-subscription/index.jsx
index 457f350b3d3b..9792db06d0e9 100644
--- a/src/pages/tenant/administration/add-subscription/index.jsx
+++ b/src/pages/tenant/administration/add-subscription/index.jsx
@@ -54,7 +54,7 @@ const Page = () => {
{/* Conditional Access Policy Selector */}
-
+
{
sortOptions={true}
/>
-
+
{
sx={{ mb: 2 }}
key={event.id}
>
-
+
{
}}
/>
-
+
{
]}
/>
-
+
{/* Show textField for String properties when NOT using in/notIn operators */}
{
-
+
{
const pageTitle = 'Alerts'
@@ -29,6 +37,37 @@ const Page = () => {
color: 'success',
target: '_self',
},
+ {
+ label: 'Enable Alert',
+ type: 'POST',
+ url: '/api/ExecToggleAlert',
+ data: {
+ ID: 'RowKey',
+ EventType: 'EventType',
+ Disabled: '!false',
+ },
+ icon: ,
+ relatedQueryKeys: 'ListAlertsQueue',
+ condition: (row) => row.Enabled !== true,
+ confirmText: 'Are you sure you want to enable this alert?',
+ multiPost: false,
+ },
+ {
+ label: 'Disable Alert',
+ type: 'POST',
+ url: '/api/ExecToggleAlert',
+ data: {
+ ID: 'RowKey',
+ EventType: 'EventType',
+ Disabled: '!true',
+ },
+ icon: ,
+ relatedQueryKeys: 'ListAlertsQueue',
+ condition: (row) => row.Enabled === true,
+ confirmText:
+ 'Are you sure you want to disable this alert? It will not run until you enable it again.',
+ multiPost: false,
+ },
{
label: 'Delete Alert',
type: 'POST',
@@ -62,6 +101,7 @@ const Page = () => {
simpleColumns={[
'Tenants',
'EventType',
+ 'Enabled',
'Conditions',
'RepeatsEvery',
'Actions',
diff --git a/src/pages/tenant/administration/applications/app-registration/index.jsx b/src/pages/tenant/administration/applications/app-registration/index.jsx
index 7bcfe12fad20..8c5e25707661 100644
--- a/src/pages/tenant/administration/applications/app-registration/index.jsx
+++ b/src/pages/tenant/administration/applications/app-registration/index.jsx
@@ -14,6 +14,7 @@ import {
Badge,
} from '@mui/icons-material'
import { HeaderedTabbedLayout } from '../../../../../layouts/HeaderedTabbedLayout'
+import { CippAppRegistrationSwitcher } from '../../../../../components/CippComponents/CippAppRegistrationSwitcher'
import tabOptions from './tabOptions'
import { CippCopyToClipBoard } from '../../../../../components/CippComponents/CippCopyToClipboard'
import { Box, Stack } from '@mui/system'
@@ -368,6 +369,13 @@ const Page = () => {
+ }
subtitle={subtitle}
actions={appData ? appActions : []}
actionsData={actionsData}
@@ -390,7 +398,7 @@ const Page = () => {
>
-
+
@@ -478,7 +486,7 @@ const Page = () => {
-
+
Credentials
{
+ }
subtitle={subtitle}
actions={appData ? appActions : []}
actionsData={actionsData}
diff --git a/src/pages/tenant/administration/applications/enterprise-app/index.jsx b/src/pages/tenant/administration/applications/enterprise-app/index.jsx
index fb17f8e88994..48d5116cf579 100644
--- a/src/pages/tenant/administration/applications/enterprise-app/index.jsx
+++ b/src/pages/tenant/administration/applications/enterprise-app/index.jsx
@@ -6,6 +6,7 @@ import CippFormSkeleton from '../../../../../components/CippFormPages/CippFormSk
import CalendarIcon from '@heroicons/react/24/outline/CalendarIcon'
import { Fingerprint, Launch, Apps, Group, CheckCircle, Warning, Badge } from '@mui/icons-material'
import { HeaderedTabbedLayout } from '../../../../../layouts/HeaderedTabbedLayout'
+import { CippEnterpriseAppSwitcher } from '../../../../../components/CippComponents/CippEnterpriseAppSwitcher'
import tabOptions from './tabOptions'
import { CippCopyToClipBoard } from '../../../../../components/CippComponents/CippCopyToClipboard'
import { Box, Stack } from '@mui/system'
@@ -288,6 +289,13 @@ const Page = () => {
+ }
subtitle={subtitle}
actions={spData ? appActions : []}
actionsData={actionsData}
@@ -305,7 +313,7 @@ const Page = () => {
-
+
@@ -393,7 +401,7 @@ const Page = () => {
-
+
Credentials
{
+ }
subtitle={subtitle}
actions={spData ? appActions : []}
actionsData={actionsData}
diff --git a/src/pages/tenant/administration/tenants/edit.js b/src/pages/tenant/administration/tenants/edit.js
index cc6d59397910..f8214e55b6e7 100644
--- a/src/pages/tenant/administration/tenants/edit.js
+++ b/src/pages/tenant/administration/tenants/edit.js
@@ -71,6 +71,7 @@ const Page = () => {
ClearImmutableId: false,
DisableOneDriveSharing: false,
removeCalendarPermissions: false,
+ OOO: "",
postExecution: {
psa: false,
email: false,
@@ -122,6 +123,7 @@ const Page = () => {
ClearImmutableId: false,
DisableOneDriveSharing: false,
removeCalendarPermissions: false,
+ OOO: "",
postExecution: {
psa: false,
email: false,
diff --git a/src/pages/tenant/administration/tenants/index.js b/src/pages/tenant/administration/tenants/index.js
index b6539a5ad323..9eb110355cd6 100644
--- a/src/pages/tenant/administration/tenants/index.js
+++ b/src/pages/tenant/administration/tenants/index.js
@@ -10,6 +10,7 @@ const Page = () => {
const simpleColumns = [
"displayName",
"defaultDomainName",
+ "tenantGroups",
"portal_m365",
"portal_exchange",
"portal_entra",
diff --git a/src/pages/tenant/baselines/alignment/index.js b/src/pages/tenant/baselines/alignment/index.js
index d50f6248e407..7f643e3bef01 100644
--- a/src/pages/tenant/baselines/alignment/index.js
+++ b/src/pages/tenant/baselines/alignment/index.js
@@ -10,6 +10,7 @@ import {
Divider,
Link,
Stack,
+ TextField,
ToggleButton,
ToggleButtonGroup,
Tooltip,
@@ -50,6 +51,7 @@ import {
LayersClear,
PlayArrow,
RemoveCircle,
+ Search,
TaskAlt,
Tune,
Visibility,
@@ -63,6 +65,7 @@ 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'
@@ -79,6 +82,7 @@ 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 = {
@@ -281,6 +285,9 @@ 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.
@@ -309,10 +316,61 @@ const outcomeTimeline = {
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.
+// 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`
@@ -408,18 +466,43 @@ const Page = () => {
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 = (index) => {
+ const toggleEventExpansion = (eventKey) => {
setExpandedEvents((prev) => {
const next = new Set(prev)
- if (next.has(index)) {
- next.delete(index)
+ if (next.has(eventKey)) {
+ next.delete(eventKey)
} else {
- next.add(index)
+ 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'
@@ -474,6 +557,13 @@ const Page = () => {
})
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 = {
@@ -1227,23 +1317,24 @@ const Page = () => {
Accept this property only
)}
- {!acceptedPath && (
- }
- onClick={() => {
- setDenyPathTarget({
- ...row,
- path: entry.Property,
- })
- denyPathDialog.handleOpen()
- }}
- >
- Deny & queue deletion
-
- )}
+ {!acceptedPath &&
+ supportsPathDeletion(row.standardName) && (
+ }
+ onClick={() => {
+ setDenyPathTarget({
+ ...row,
+ path: entry.Property,
+ })
+ denyPathDialog.handleOpen()
+ }}
+ >
+ Deny & queue deletion
+
+ )}
)
@@ -1387,20 +1478,22 @@ const Page = () => {
Accept this property only
)}
- {drifted && !acceptedPath && (
- }
- onClick={() => {
- setDenyPathTarget({ ...row, path: key })
- denyPathDialog.handleOpen()
- }}
- >
- Deny & queue deletion
-
- )}
+ {drifted &&
+ !acceptedPath &&
+ supportsPathDeletion(row.standardName) && (
+ }
+ onClick={() => {
+ setDenyPathTarget({ ...row, path: key })
+ denyPathDialog.handleOpen()
+ }}
+ >
+ Deny & queue deletion
+
+ )}
)
@@ -1515,14 +1608,8 @@ const Page = () => {
{
{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
+
)
@@ -1570,6 +1683,32 @@ const Page = () => {
: '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,
+ })),
+ },
+ ]}
+ />
+
+ )}
{
>
)
- // Historic view: the tenant's run events on an activity timeline (same pattern as the
- // manage-tenant history page). Each event carries its run GUID; View Logs opens the
- // Baselines log drawer filtered to exactly that run's entries.
+ // 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 (
<>
@@ -2301,9 +2502,95 @@ const Page = () => {
/>
- This timeline shows every recorded baseline run event for{' '}
- {tenant.displayName}.
+ 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 && (
@@ -2315,7 +2602,14 @@ const Page = () => {
first.
)}
- {historyEvents.length > 0 && (
+ {!historyApi.isFetching &&
+ historyEvents.length > 0 &&
+ filteredEvents.length === 0 && (
+
+ No events match the current filters.
+
+ )}
+ {renderRows.length > 0 && (
{
[`& .MuiTimelineContent-root`]: { flex: 0.8 },
}}
>
- {historyEvents.map((event, index) => {
+ {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 isExpanded = expandedEvents.has(index)
+ 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 (
-
+
{
>
{timelineConfig.icon}
- {index < historyEvents.length - 1 && (
+ {index < renderRows.length - 1 && (
)}
@@ -2415,6 +2891,15 @@ const Page = () => {
sx={{ fontSize: '0.7rem', height: 20 }}
/>
+ {event.alerted && (
+
+ )}
{
toggleEventExpansion(index)}
+ onClick={() =>
+ toggleEventExpansion(eventKey)
+ }
sx={{
textAlign: 'left',
fontSize: '0.75rem',
@@ -2505,6 +2992,15 @@ const Page = () => {
)}
+ {runGroups.length > historyLimit && (
+ setHistoryLimit((prev) => prev + 50)}
+ >
+ Load more (showing {historyLimit} of {runGroups.length} entries)
+
+ )}
{dialogs}
diff --git a/src/pages/tenant/baselines/template.jsx b/src/pages/tenant/baselines/template.jsx
index 4503bb68f9b9..f6ecf1b6986f 100644
--- a/src/pages/tenant/baselines/template.jsx
+++ b/src/pages/tenant/baselines/template.jsx
@@ -51,6 +51,7 @@ import { CippApiResults } from '../../../components/CippComponents/CippApiResult
const conditionTypeOptions = [
{ label: 'Time in previous stage', value: 'time' },
{ label: 'Tenant variable', value: 'variable' },
+ { label: 'Is in tenant group', value: 'group' },
{ label: 'All previous stage items applied successfully', value: 'success' },
{ label: 'Manual approval by an operator', value: 'manual' },
]
@@ -87,6 +88,10 @@ const toConditionDefaults = (condition) => ({
(option) => option.value === condition.operator
),
value: condition.value,
+ // The stored groupName keeps the picker readable without waiting for the group list.
+ group: condition.group
+ ? { label: condition.groupName ?? condition.group, value: condition.group }
+ : undefined,
})
// The API serializes single-element arrays as a bare object; normalize before handing
@@ -142,6 +147,7 @@ const StagePanel = ({
catalogByName,
registerSerializer,
variableOptions,
+ groupOptions,
}) => {
const formControl = useForm({
mode: 'onBlur',
@@ -150,7 +156,13 @@ const StagePanel = ({
conditions: stage.conditionDefaults,
},
})
- const watchForm = useWatch({ control: formControl.control })
+ // Watch ONLY the conditions branch. A whole-form watch re-renders this panel - and
+ // every standard item in it - on each keystroke in any of the standards' fields,
+ // which makes the editor crawl once a few hundred standards are loaded.
+ const watchConditions = useWatch({
+ control: formControl.control,
+ name: 'conditions',
+ })
const [expandedStandard, setExpandedStandard] = useState(null)
const [conditionIds, setConditionIds] = useState(stage.conditionIds)
const [nextConditionId, setNextConditionId] = useState(
@@ -189,8 +201,13 @@ const StagePanel = ({
{ label: 'Alert when remediated', field: 'alertOnRemediate', value: true },
]
const applyPostureToAll = (field, value) => {
+ // Force the value onto every standard: dirty + touched so the form registers
+ // the change even on fields the operator never interacted with.
stage.standards.forEach((instanceKey) => {
- formControl.setValue(`${instanceKey}.${field}`, value)
+ formControl.setValue(`${instanceKey}.${field}`, value, {
+ shouldDirty: true,
+ shouldTouch: true,
+ })
})
}
@@ -223,20 +240,30 @@ const StagePanel = ({
variable: unwrapValue(condition.variable),
operator: unwrapValue(condition.operator),
value: condition.value,
+ group: unwrapValue(condition.group),
+ groupName: condition.group?.label ?? unwrapValue(condition.group),
}
}),
standards: stage.standards.map((instanceKey) => {
const config = values[instanceKey] ?? {}
+ // A standard the operator never expanded never mounts its settings fields,
+ // so its variables never enter the form - serialize the SAVED variables for
+ // those, or saving a large baseline would silently wipe their configuration.
+ // Unwrapped either way: legacy saves stored option objects ({label, value})
+ // for some variables, and passing them through verbatim keeps that debt alive.
+ const savedVariables =
+ stage.standardConfigs?.[instanceKey]?.variables ?? {}
return {
standard: instanceKey.split('#')[0],
instance: instanceKey,
variables: Object.fromEntries(
- Object.entries(config.variables ?? {}).map(([key, value]) => [
- key,
- unwrapValue(value),
- ])
+ Object.entries(config.variables ?? savedVariables).map(
+ ([key, value]) => [key, unwrapValue(value)]
+ )
),
- remediateEnabled: config.remediateEnabled ?? true,
+ // Report-only unless the operator explicitly enabled remediation - a
+ // missing value must never fail open into auto-fixing tenants.
+ remediateEnabled: config.remediateEnabled ?? false,
alertEnabled: config.alertEnabled ?? true,
alertOnRemediate: config.alertOnRemediate ?? false,
}
@@ -299,11 +326,10 @@ const StagePanel = ({
Graduation conditions
- Tenants graduate from Stage {stageIndex} into this stage when the
- conditions below are met. Stages are cumulative: a tenant in this
- stage also receives everything from the previous stages. If this
- stage configures a standard an earlier stage also configures, the
- settings here replace the earlier ones once the tenant arrives.
+ 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
+ win.
{conditionIds.length > 1 && (
@@ -326,8 +352,8 @@ const StagePanel = ({
)}
{conditionIds.map((conditionId) => {
const conditionType = get(
- watchForm,
- `conditions.${conditionId}.type`
+ watchConditions,
+ `${conditionId}.type`
)?.value
return (
@@ -421,6 +447,19 @@ const StagePanel = ({
)}
+ {conditionType === 'group' && (
+
+
+
+ )}
{conditionType === 'success' && (
Advances when every standard from the previous stages
@@ -522,6 +561,10 @@ const Page = () => {
const router = useRouter()
const [activeStage, setActiveStage] = useState(0)
const [loadedTemplateId, setLoadedTemplateId] = useState(null)
+ // The GUID the next save updates. Null means the save CREATES a baseline (new
+ // editor, or a clone before its first save); the save response's id is adopted
+ // so saving twice never creates twice.
+ const [saveTargetId, setSaveTargetId] = useState(null)
const [stages, setStages] = useState(() => buildEditorStages(undefined))
const [dialogOpen, setDialogOpen] = useState(false)
const [dialogStageIndex, setDialogStageIndex] = useState(0)
@@ -541,6 +584,23 @@ const Page = () => {
// and table), all alignment views for every tenant, and the standards catalog.
const saveBaseline = ApiPostCall({
relatedQueryKeys: ['ListBaseline*'],
+ onResult: (result) => {
+ const savedId = result?.Metadata?.id
+ if (!savedId) return
+ // Adopt the saved baseline: the next save updates it instead of creating a
+ // duplicate, and the URL reflects it so a refresh keeps editing the same one.
+ // Matching loadedTemplateId also stops the render-phase loader from
+ // re-resetting the form when the refetched list arrives.
+ setSaveTargetId(savedId)
+ setLoadedTemplateId(savedId)
+ if (router.query.id !== savedId || router.query.clone) {
+ router.replace(
+ { pathname: router.pathname, query: { id: savedId } },
+ undefined,
+ { shallow: true }
+ )
+ }
+ },
})
// After a save, the natural next step is seeing where the tenants stand - offer a
// no-changes check right away instead of ending the setup flow in silence.
@@ -551,6 +611,15 @@ const Page = () => {
url: '/api/ListCustomVariables',
queryKey: 'ListCustomVariables',
})
+ // Same query key as the Edit Tenant group picker so both share one cached list.
+ const tenantGroupsApi = ApiGetCall({
+ url: '/api/ListTenantGroups',
+ queryKey: 'AllTenantGroups',
+ })
+ const groupOptions = (tenantGroupsApi.data?.Results ?? []).map((group) => ({
+ label: group.Name,
+ value: group.Id,
+ }))
// Graduation conditions compare against CIPP custom variables; reserved tenant tokens
// are not useful graduation signals. Creatable, so any variable name can be typed.
const variableOptions = (customVariablesApi.data?.Results ?? [])
@@ -571,6 +640,7 @@ const Page = () => {
description: '',
alertEmails: '',
alertWebhookUrl: '',
+ disableScheduledRuns: false,
},
})
const watchForm = useWatch({ control: formControl.control })
@@ -579,6 +649,7 @@ const Page = () => {
// Render-phase reset (not an effect) so the switch happens before anything paints.
if (template && template.GUID !== loadedTemplateId) {
setLoadedTemplateId(template.GUID)
+ setSaveTargetId(router.query.clone ? null : template.GUID)
setStages(buildEditorStages(template))
setActiveStage(0)
setHasUnsavedChanges(false)
@@ -589,6 +660,7 @@ const Page = () => {
description: template.description,
alertEmails: template.alertEmails ?? '',
alertWebhookUrl: template.alertWebhookUrl ?? '',
+ disableScheduledRuns: template.disableScheduledRuns === true,
// The tenant selector's own option objects round-trip verbatim through the API
// (assignments/exclusions); older saves fall back to name-based options.
tenantFilter:
@@ -791,7 +863,7 @@ const Page = () => {
saveBaseline.mutate({
url: '/api/AddBaseline',
data: {
- GUID: router.query.clone ? undefined : (loadedTemplateId ?? undefined),
+ GUID: saveTargetId ?? undefined,
templateName: values.templateName,
description: values.description,
// Send the selector's option objects as-is (label/value/type) so they can be
@@ -808,6 +880,7 @@ const Page = () => {
),
alertEmails: values.alertEmails,
alertWebhookUrl: values.alertWebhookUrl,
+ disableScheduledRuns: values.disableScheduledRuns === true,
stages: stages.map(
(stage, index) =>
stageSerializers.current[index]?.() ?? {
@@ -844,14 +917,19 @@ const Page = () => {
{pageTitle}
-
+
{
required={false}
disableClearable={false}
/>
+
+
+ With scheduled runs disabled, this baseline only executes
+ when you run it yourself - drift is not detected or
+ remediated in between.
+
@@ -1077,6 +1166,7 @@ const Page = () => {
catalogByName={catalogByName}
registerSerializer={registerSerializer}
variableOptions={variableOptions}
+ groupOptions={groupOptions}
/>
))}
diff --git a/src/pages/tenant/baselines/templates/index.js b/src/pages/tenant/baselines/templates/index.js
index 9fd321c5f0c5..4eeda181f51d 100644
--- a/src/pages/tenant/baselines/templates/index.js
+++ b/src/pages/tenant/baselines/templates/index.js
@@ -1,11 +1,19 @@
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'
@@ -16,7 +24,9 @@ import {
CopyAll,
Delete,
Edit,
+ GitHub,
PlayArrow,
+ Upgrade,
} from '@mui/icons-material'
import { Layout as DashboardLayout } from '../../../../layouts/index.js'
import { TabbedLayout } from '../../../../layouts/TabbedLayout'
@@ -27,6 +37,8 @@ import { CippOffCanvas } from '../../../../components/CippComponents/CippOffCanv
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) =>
@@ -37,6 +49,52 @@ const asOptionArray = (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 = [
{
@@ -76,6 +134,43 @@ const Page = () => {
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',
@@ -112,6 +207,10 @@ const Page = () => {
{ 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
@@ -289,6 +388,203 @@ const Page = () => {
>
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}
+
+ ))}
+
+ }
+ />
+
+ )
+ })}
+
+
+
{
-
+
Use this form to generate invites for the selected GDAP Role Template. After
generating the invite, you will receive two URLs:
@@ -122,7 +123,7 @@ const Page = () => {
{" "}
in Application Settings.
-
+
{createDefaults && (
<>
diff --git a/src/pages/tenant/gdap-management/offboarding.js b/src/pages/tenant/gdap-management/offboarding.js
index aa6cddc87d0d..5cfc5be94fbf 100644
--- a/src/pages/tenant/gdap-management/offboarding.js
+++ b/src/pages/tenant/gdap-management/offboarding.js
@@ -226,6 +226,12 @@ const Page = () => {
label="Remove all Domain Analyser results for this tenant."
type="switch"
/>
+
diff --git a/src/pages/tenant/gdap-management/relationships/relationship/index.js b/src/pages/tenant/gdap-management/relationships/relationship/index.js
index 7a93d35548c2..df4c3f3004ca 100644
--- a/src/pages/tenant/gdap-management/relationships/relationship/index.js
+++ b/src/pages/tenant/gdap-management/relationships/relationship/index.js
@@ -3,6 +3,7 @@ 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";
@@ -135,6 +136,7 @@ const Page = () => {
}
subtitle={subtitle}
isFetching={relationshipRequest.isLoading}
actions={CippGdapActions()}
diff --git a/src/pages/tenant/gdap-management/relationships/relationship/mappings.js b/src/pages/tenant/gdap-management/relationships/relationship/mappings.js
index b9669e3f725f..383dfc279451 100644
--- a/src/pages/tenant/gdap-management/relationships/relationship/mappings.js
+++ b/src/pages/tenant/gdap-management/relationships/relationship/mappings.js
@@ -2,6 +2,7 @@ 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";
@@ -45,6 +46,7 @@ const Page = () => {
}
subtitle={subtitle}
isFetching={relationshipRequest.isLoading}
backUrl="/tenant/gdap-management/relationships"
diff --git a/src/pages/tenant/gdap-management/roles/add.js b/src/pages/tenant/gdap-management/roles/add.js
index 6c2e2a808f22..3cd90ac53a9e 100644
--- a/src/pages/tenant/gdap-management/roles/add.js
+++ b/src/pages/tenant/gdap-management/roles/add.js
@@ -13,6 +13,7 @@ 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 = () => {
@@ -217,7 +218,7 @@ const Page = () => {
compareType="is"
compareValue={true}
>
-
+
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
@@ -244,7 +245,7 @@ const Page = () => {
on GDAP Role Guidance.
-
+
{
mt: 2,
}}
>
-
+
{
sx={{ height: "100%", display: "flex", flexDirection: "column" }}
>
Backup History
-
+
{settings.currentTenant === "AllTenants" && (
-
+
{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
+ flexWrap: "wrap",
+ rowGap: 1,
+ columnGap: 1,
}}
>
-
+
{(() => {
const match = backup.name.match(
@@ -485,7 +499,12 @@ const Page = () => {
/>
)}
-
+
{
]
// API calls for drift data
+ // Hoisted so the header actions invalidate the same query this page reads.
+ const driftQueryKey = `TenantDrift-${tenantFilter}`
const driftApi = ApiGetCall({
url: '/api/listTenantDrift',
data: {
tenantFilter: tenantFilter,
},
- queryKey: `TenantDrift-${tenantFilter}`,
+ queryKey: driftQueryKey,
})
// API call for available drift templates (for What If dropdown)
@@ -1724,6 +1726,7 @@ const ManageDriftPage = () => {
title={title}
subtitle={subtitle}
actions={actions}
+ queryKeys={driftQueryKey}
actionsData={{}}
isFetching={
driftApi.isFetching ||
diff --git a/src/pages/tenant/manage/edit.js b/src/pages/tenant/manage/edit.js
index a1aa8e3c88ad..8c8076e0d4c3 100644
--- a/src/pages/tenant/manage/edit.js
+++ b/src/pages/tenant/manage/edit.js
@@ -149,6 +149,7 @@ const Page = () => {
ClearImmutableId: false,
DisableOneDriveSharing: false,
removeCalendarPermissions: false,
+ OOO: '',
}
let offboardingDefaults = {}
@@ -190,6 +191,7 @@ const Page = () => {
ClearImmutableId: false,
DisableOneDriveSharing: false,
removeCalendarPermissions: false,
+ OOO: '',
}
offboardingFormControl.reset({ offboardingDefaults: defaultOffboardingValues })
diff --git a/src/pages/tenant/manage/history.js b/src/pages/tenant/manage/history.js
index 129fcad963b4..0e040878bd98 100644
--- a/src/pages/tenant/manage/history.js
+++ b/src/pages/tenant/manage/history.js
@@ -82,9 +82,11 @@ const Page = () => {
const { startDate, endDate } = getDateRange(daysToLoad);
+ // Hoisted so the header actions invalidate the same query this page reads.
+ const logsQueryKey = `Listlogs-${tenant}-${startDate}-${endDate}`;
const logsData = ApiGetCall({
url: `/api/Listlogs?tenant=${tenant}&StartDate=${startDate}&EndDate=${endDate}&Filter=true`,
- queryKey: `Listlogs-${tenant}-${startDate}-${endDate}`,
+ queryKey: logsQueryKey,
});
// Get severity icon and color
@@ -149,6 +151,7 @@ const Page = () => {
tabOptions={tabOptions}
title={title}
actions={actions}
+ queryKeys={logsQueryKey}
actionsData={{}}
isFetching={logsData.isLoading}
>
diff --git a/src/pages/tenant/manage/user-defaults.js b/src/pages/tenant/manage/user-defaults.js
index 32f97ae0b788..48e24a4812ed 100644
--- a/src/pages/tenant/manage/user-defaults.js
+++ b/src/pages/tenant/manage/user-defaults.js
@@ -1,7 +1,8 @@
import { Layout as DashboardLayout } from '../../../layouts/index.js'
-import { TabbedLayout } from '../../../layouts/TabbedLayout'
-import { CippTablePage } from '../../../components/CippComponents/CippTablePage.jsx'
-import { Button } from '@mui/material'
+import { HeaderedTabbedLayout } from '../../../layouts/HeaderedTabbedLayout'
+import { CippDataTable } from '../../../components/CippTable/CippDataTable'
+import { CippHead } from '../../../components/CippComponents/CippHead'
+import { Box, Button } from '@mui/material'
import { Delete, Add, Edit } from '@mui/icons-material'
import { useDialog } from '../../../hooks/use-dialog'
import { CippApiDialog } from '../../../components/CippComponents/CippApiDialog'
@@ -330,28 +331,31 @@ const Page = () => {
}
return (
- <>
- } onClick={createDialog.handleOpen} sx={{ mr: 1 }}>
- Add Template
-
- }
- />
+
+
+
+ } onClick={createDialog.handleOpen} sx={{ mr: 1 }}>
+ Add Template
+
+ }
+ />
+
{
...templateFields,
]}
/>
- >
+
)
}
-Page.getLayout = (page) => (
-
- {page}
-
-)
+Page.getLayout = (page) => {page}
export default Page
diff --git a/src/pages/tenant/reports/application-consent/index.js b/src/pages/tenant/reports/application-consent/index.js
index 08ac8b35d47c..28c07f3bc3de 100644
--- a/src/pages/tenant/reports/application-consent/index.js
+++ b/src/pages/tenant/reports/application-consent/index.js
@@ -31,7 +31,7 @@ const Page = () => {
apiUrl={reportDB.resolvedApiUrl}
queryKey={reportDB.resolvedQueryKey}
simpleColumns={simpleColumns}
- cardButton={reportDB.controls}
+ dataSourceControls={reportDB.controls}
/>
{reportDB.syncDialog}
>
diff --git a/src/pages/tenant/reports/graph-office-reports/index.js b/src/pages/tenant/reports/graph-office-reports/index.js
index c993de0ffcf0..3629072e6bd5 100644
--- a/src/pages/tenant/reports/graph-office-reports/index.js
+++ b/src/pages/tenant/reports/graph-office-reports/index.js
@@ -95,7 +95,7 @@ const Page = () => {
{/* Toolbar */}
-
+
{
size={{
md: layoutMode === "Table" ? 12 : 4,
sm: layoutMode === "Table" ? 12 : 6,
- xs: 10,
+ xs: 12,
}}
key={block.id}
>
@@ -264,14 +264,14 @@ const Page = () => {
-
+
{pageTitle}
@@ -144,7 +144,7 @@ const Page = () => {
{currentTenant === "AllTenants" && layoutMode !== "Table" ? (
-
+
{
<>
{blockCards.map((block, index) => (
{
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
-
+
diff --git a/src/pages/tenant/standards/templates/template.jsx b/src/pages/tenant/standards/templates/template.jsx
index 7e442863b3f1..95c1398f260a 100644
--- a/src/pages/tenant/standards/templates/template.jsx
+++ b/src/pages/tenant/standards/templates/template.jsx
@@ -367,10 +367,10 @@ const Page = () => {
@@ -382,7 +382,12 @@ const Page = () => {
? 'Add Drift Template'
: 'Add Standards Template'}
-
+
{
>
-
+
-
+
{
required
/>
-
+
setIpAddress(ip)}
@@ -119,11 +119,11 @@ const Page = () => {
-
+
{/* Results Card */}
{ipAddress && (
-
+
@@ -146,7 +146,7 @@ const Page = () => {
)}
-
+
{
-
+
{
required
/>
-
+
getGeoIP.refetch()}
@@ -87,7 +87,7 @@ const Page = () => {
{/* Export Button */}
{getGeoIP.data && getGeoIP.data.length > 0 && (
-
+
{
)}
{getGeoIP.isFetching ? (
-
+
@@ -107,10 +107,10 @@ const Page = () => {
) : getGeoIP.data ? (
<>
{getGeoIP.data.length === 0 && (
-
+
-
+
No breaches have been detected for this account
@@ -120,7 +120,7 @@ const Page = () => {
)}
{getGeoIP.data?.map((breach, index) => (
-
+
{breach.Title}>}
@@ -133,7 +133,7 @@ const Page = () => {
}
>
-
+
Partial Password Available
@@ -214,7 +214,7 @@ const Page = () => {
) : (
<>
{getGeoIP.isSuccess && (
-
+
@@ -227,7 +227,7 @@ const Page = () => {
)}
{getGeoIP.isError && (
-
+
diff --git a/src/pages/tools/community-repos/index.js b/src/pages/tools/community-repos/index.js
index 72494f4997b7..acc83ad832e9 100644
--- a/src/pages/tools/community-repos/index.js
+++ b/src/pages/tools/community-repos/index.js
@@ -51,6 +51,7 @@ const typeOptions = [
{ label: "Intune Policy", value: "IntuneTemplate" },
{ label: "Conditional Access", value: "CATemplate" },
{ label: "Standards", value: "StandardsTemplateV2" },
+ { label: "Baseline", value: "BaselineTemplate" },
{ label: "Report Builder", value: "ReportBuilderTemplate" },
{ label: "Group", value: "GroupTemplate" },
{ label: "Custom Test", value: "CustomTest" },
diff --git a/src/pages/tools/report-builder/builder/index.js b/src/pages/tools/report-builder/builder/index.js
index 88cf7e36219d..1899270826f7 100644
--- a/src/pages/tools/report-builder/builder/index.js
+++ b/src/pages/tools/report-builder/builder/index.js
@@ -34,6 +34,8 @@ import CippButtonCard from '../../../../components/CippCards/CippButtonCard'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { renderCustomScriptMarkdownTemplate } from '../../../../utils/customScriptTemplate'
+import { getCippLicenseTranslation } from '../../../../utils/get-cipp-license-translation'
+import { isCloudPcDevice } from '../../../../utils/is-cloud-pc-device'
import {
escapeTableCell,
isTableSeparatorRow,
@@ -740,6 +742,14 @@ const DatabaseBlock = ({
}
/* ── Format database content helper ─────────────────────── */
+
+// License assignments come out of the cache as objects carrying skuId GUIDs; render the product
+// names instead. Matches the shape check the backend applies when the report is generated.
+const isLicenseAssignmentValue = (val) => {
+ const items = Array.isArray(val) ? val : [val]
+ return items.length > 0 && items.every((v) => v && typeof v === 'object' && 'skuId' in v)
+}
+
const formatDatabaseContent = (data, selectedHeaders, format) => {
if (!data || !selectedHeaders || selectedHeaders.length === 0) return ''
@@ -750,7 +760,14 @@ const formatDatabaseContent = (data, selectedHeaders, format) => {
const filtered = rows.map((row) => {
const obj = {}
selectedHeaders.forEach((h) => {
- obj[h] = row[h] !== undefined && row[h] !== null ? row[h] : ''
+ const val = row[h] !== undefined && row[h] !== null ? row[h] : ''
+ if (h === 'isEncrypted' && val !== true && isCloudPcDevice(row)) {
+ // Cloud PCs never report BitLocker but are platform-encrypted by Azure. Matches the
+ // cell rendering the backend applies when the report is generated.
+ obj[h] = 'Encrypted (platform-managed)'
+ } else {
+ obj[h] = isLicenseAssignmentValue(val) ? getCippLicenseTranslation(val).join(', ') : val
+ }
})
return obj
})
@@ -1204,6 +1221,31 @@ const Page = () => {
}
}
+ // One click after picking a suite adds every test in it, instead of selecting them one by one.
+ const handleAddAllSuiteTests = () => {
+ if (!watchTestSuite?.value || filteredTestOptions.length === 0) return
+ setBlocks((prev) => [
+ ...prev,
+ ...filteredTestOptions.map((test, i) => ({
+ id: `block-${Date.now()}-${i}`,
+ type: 'test',
+ testId: test.value,
+ testCategory: test.category,
+ title: test.name || test.label,
+ content: getTestContent(test.value),
+ status: getTestStatus(test.value),
+ static: false,
+ })),
+ ])
+ addBlockForm.reset({
+ blockType: null,
+ testSuite: null,
+ selectedTest: [],
+ dbCacheType: null,
+ dbFormat: null,
+ })
+ }
+
const handleRemoveBlock = (index) => setBlocks((prev) => prev.filter((_, i) => i !== index))
const handleMoveBlockUp = (index) => {
@@ -1509,6 +1551,17 @@ const Page = () => {
disabled={!watchTestSuite?.value}
/>
+
+ }
+ onClick={handleAddAllSuiteTests}
+ disabled={!watchTestSuite?.value || filteredTestOptions.length === 0}
+ >
+ Add All Tests
+
+
{
isFetching={brandingPresetsApi.isFetching}
/>
-
+
{
options={PAGE_SIZES}
/>
-
+
{
swaStatus.isSuccess && !!swaStatus?.data?.clientPrincipal && userRoles.length > 0
const signedInAs = swaStatus?.data?.clientPrincipal?.userDetails
- const isSessionEnded = reason === 'session'
+ // Server-side re-check of Entra group membership, for roles granted through a PIM-activated
+ // group. Invalidating authmecipp makes PrivateRoute refetch /api/me, so a successful
+ // elevation walks the user straight into the app without another sign-in.
+ const [refreshResult, setRefreshResult] = useState(null)
+ const refreshAccess = ApiPostCall({
+ relatedQueryKeys: ['authmecipp'],
+ onResult: (result) =>
+ setRefreshResult({
+ severity: result?.Roles?.length > 0 ? 'success' : 'info',
+ text: result?.Results ?? 'Access refreshed.',
+ }),
+ })
+ const handleRefreshAccess = () => {
+ setRefreshResult(null)
+ refreshAccess.mutate(
+ { url: '/api/ExecRefreshMyAccess', data: {} },
+ { onError: (error) => setRefreshResult({ severity: 'warning', text: getCippError(error) }) }
+ )
+ }
+
+ // A signed-in identity plus a /me message is not a missing session — it's a denial the
+ // server explained (e.g. "your IP is not in the allowed range"). Show the explanation
+ // instead of the generic sign-in prompt, whatever reason the caller guessed. Without a
+ // SWA identity there is nobody to deny, so a stale message must not hide the sign-in.
+ const hasIdentity = Boolean(swaStatus?.data?.clientPrincipal)
+ const isSessionEnded = reason === 'session' && !(hasIdentity && orgData?.data?.message)
const sessionProps = {
title: 'Sign in to CIPP',
@@ -108,6 +135,35 @@ const Page = ({ reason = 'session' }) => {
actionHref: loginUrl(),
secondaryText: canReturnHome ? 'Return to Home' : undefined,
secondaryHref: canReturnHome ? '/' : undefined,
+ busy: refreshAccess.isPending,
+ // below the card rather than in its button row: both slots are taken when the user
+ // already holds roles, and that is exactly the PIM case (standing readonly, elevated
+ // to admin) this affordance exists for
+ children: (
+
+ {refreshResult && {refreshResult.text} }
+
+ }
+ onClick={handleRefreshAccess}
+ disabled={refreshAccess.isPending}
+ >
+ Refresh my access
+
+
+ Just activated a role through PIM? Re-check your access.
+
+
+
+ ),
}
return (
@@ -115,6 +171,9 @@ const Page = ({ reason = 'session' }) => {
{isSessionEnded ? 'Sign in - CIPP' : '401 - Access Denied'}
+ {/* If an impersonated role can't load /me, this page is what renders — the exit
+ affordance must exist here or the user is stuck until they clear localStorage. */}
+
{(orgData.isSuccess || swaStatus.isSuccess) && Array.isArray(userRoles) && (
{
disableRipple: true,
},
},
+ MuiAccordionDetails: {
+ styleOverrides: {
+ // An accordion is almost always nested inside a card that already pays for gutters,
+ // and its own content usually adds a third layer. Halve the horizontal padding on a
+ // phone so the innermost text is not reading through 70px of chrome.
+ root: {
+ "@media (max-width: 899.95px)": {
+ paddingLeft: 8,
+ paddingRight: 8,
+ },
+ },
+ },
+ },
+ MuiAccordionSummary: {
+ styleOverrides: {
+ root: {
+ "@media (max-width: 899.95px)": {
+ paddingLeft: 8,
+ paddingRight: 8,
+ },
+ },
+ },
+ },
MuiCardActions: {
styleOverrides: {
root: {
@@ -78,6 +101,10 @@ export const createComponents = () => {
paddingLeft: 24,
paddingRight: 24,
paddingTop: 16,
+ "@media (max-width: 899.95px)": {
+ paddingLeft: 16,
+ paddingRight: 16,
+ },
},
},
},
@@ -88,6 +115,13 @@ export const createComponents = () => {
paddingLeft: 24,
paddingRight: 24,
paddingTop: 20,
+ // 48px of the 390 a phone has is 12% of the screen spent on one card's gutters,
+ // and cards nest — a card inside an accordion inside a page card pays it three
+ // times over. Vertical padding is left alone; it isn't what runs out.
+ "@media (max-width: 899.95px)": {
+ paddingLeft: 16,
+ paddingRight: 16,
+ },
},
},
},
@@ -98,6 +132,11 @@ export const createComponents = () => {
paddingLeft: 24,
paddingRight: 24,
paddingTop: 16,
+ // Matches MuiCardContent, or the header would sit inset from its own card body.
+ "@media (max-width: 899.95px)": {
+ paddingLeft: 16,
+ paddingRight: 16,
+ },
},
subheader: {
fontSize: 14,
@@ -176,6 +215,24 @@ export const createComponents = () => {
},
},
},
+ MuiDialog: {
+ styleOverrides: {
+ paper: {
+ // Below md a centred dialog wastes both screen edges and clips long forms, and
+ // there are ~70 dialogs in the app that never opted into fullScreen. Give them
+ // the full width and the full available height here rather than per call site.
+ // Height stays content-driven, so a two-line confirmation doesn't become an
+ // empty full screen. Dialogs that already pass fullScreen are unaffected.
+ "@media (max-width: 899.95px)": {
+ margin: 0,
+ width: "100%",
+ maxWidth: "100%",
+ maxHeight: "100%",
+ borderRadius: 0,
+ },
+ },
+ },
+ },
MuiDialogActions: {
styleOverrides: {
root: {
@@ -186,6 +243,21 @@ export const createComponents = () => {
"&>:not(:first-of-type)": {
marginLeft: 16,
},
+ "@media (max-width: 899.95px)": {
+ // 32px of side padding is a lot of a 390px screen.
+ paddingBottom: 16,
+ paddingLeft: 16,
+ paddingRight: 16,
+ paddingTop: 16,
+ // Spacing as gap, not margin-left. `:first-of-type` counts per element type, so an
+ // actions row of [caption div, button, button] gave the FIRST button no margin and
+ // the second 16px — invisible in a row, but once the row stacks on a phone the two
+ // buttons sit at different left edges and different widths. gap works either way.
+ gap: 8,
+ "&>:not(:first-of-type)": {
+ marginLeft: 0,
+ },
+ },
},
},
},
@@ -232,9 +304,17 @@ export const createComponents = () => {
root: {
borderRadius: 6,
padding: 8,
+ // Touch devices get 44px hit targets without changing desktop density —
+ // pointer:coarse only matches touch-primary input.
+ "@media (pointer: coarse)": {
+ padding: 10,
+ },
},
sizeSmall: {
padding: 4,
+ "@media (pointer: coarse)": {
+ padding: 8,
+ },
},
},
},
@@ -254,6 +334,11 @@ export const createComponents = () => {
styleOverrides: {
input: {
fontSize: 14,
+ // iOS Safari zooms the viewport when a focused input's text is under 16px, and
+ // never zooms back out. Touch devices get 16px; pointer devices keep 14.
+ "@media (pointer: coarse)": {
+ fontSize: 16,
+ },
height: "40px", // Apply height only to single-line inputs
"&.MuiInputBase-inputMultiline": {
height: "unset", // Allow textareas to be flexible
@@ -291,6 +376,11 @@ export const createComponents = () => {
input: {
padding: "0 12px", // Adds padding to the left and right of the text
fontSize: 14,
+ // iOS Safari zooms the viewport when a focused input's text is under 16px, and
+ // never zooms back out. Touch devices get 16px; pointer devices keep 14.
+ "@media (pointer: coarse)": {
+ fontSize: 16,
+ },
height: "40px", // Height for single-line input fields only
"&.MuiInputBase-inputMultiline": {
height: "unset", // Exclude multiline inputs (textareas) from fixed height
@@ -427,6 +517,17 @@ export const createComponents = () => {
},
},
},
+ MuiTooltip: {
+ defaultProps: {
+ // MUI's Tooltip attaches no touchmove and no scroll listener, so a press held
+ // through a scroll opens the tooltip after 700ms and nothing is scheduled to close
+ // it until the finger lifts — it rides the page as you drag. A tooltip is a hover
+ // affordance and touch has no hover, so the long-press variant is not worth the
+ // defect. Sites that genuinely want one opt back in with disableTouchListener={false}
+ // (CippJSONView's field descriptions are the only one).
+ disableTouchListener: true,
+ },
+ },
MuiTextField: {
defaultProps: {
variant: "filled",
diff --git a/src/utils/cippVersion.js b/src/utils/cippVersion.js
index ed64050c0759..4f20325c8d14 100644
--- a/src/utils/cippVersion.js
+++ b/src/utils/cippVersion.js
@@ -26,12 +26,17 @@ export async function getCippVersion() {
return fetchPromise;
}
+import { getImpersonatedRole } from "./impersonation";
+
// Build headers including X-CIPP-Version. Accept extra headers to merge.
export async function buildVersionedHeaders(extra = {}) {
const version = await getCippVersion();
+ // Backend honors this only for real superadmins; harmless for everyone else.
+ const impersonatedRole = getImpersonatedRole();
return {
"Content-Type": "application/json",
"X-CIPP-Version": version,
+ ...(impersonatedRole ? { "x-cipp-impersonate-role": impersonatedRole } : {}),
...extra,
};
}
diff --git a/src/utils/csv-field-values.js b/src/utils/csv-field-values.js
new file mode 100644
index 000000000000..7a61882af298
--- /dev/null
+++ b/src/utils/csv-field-values.js
@@ -0,0 +1,50 @@
+/**
+ * Pull values from CSV rows for a named column (case-insensitive, trimmed header match).
+ */
+export const extractCsvColumnValues = (csvRows, csvColumn) => {
+ if (!csvColumn || !Array.isArray(csvRows) || csvRows.length === 0) {
+ return []
+ }
+ const colLower = String(csvColumn).trim().toLowerCase()
+ return csvRows
+ .map((row) => {
+ if (!row || typeof row !== 'object') return null
+ const key = Object.keys(row).find((k) => k.trim().toLowerCase() === colLower)
+ return key ? String(row[key]).trim() : null
+ })
+ .filter((v) => v != null && v !== '')
+}
+
+/**
+ * Flatten autocomplete form values to plain string ids/UPNs.
+ */
+export const normalizeAutoCompleteValues = (value) => {
+ const items = Array.isArray(value) ? value : value != null && value !== '' ? [value] : []
+ return items
+ .filter(Boolean)
+ .map((item) =>
+ typeof item === 'object' && item?.value != null
+ ? String(item.value)
+ : item != null
+ ? String(item)
+ : null
+ )
+ .filter(Boolean)
+}
+
+/**
+ * Merge autocomplete + optional CSV companion field (`${name}__csv`) into a flat string array.
+ */
+export const mergeCsvFormFields = (formData, fields) => {
+ if (!fields?.length) return formData
+ const merged = { ...formData }
+ fields.forEach((field) => {
+ if (!field.csvColumn || !field.name) return
+ const csvFieldName = `${field.name}__csv`
+ const acValues = normalizeAutoCompleteValues(merged[field.name])
+ const csvValues = extractCsvColumnValues(merged[csvFieldName], field.csvColumn)
+ merged[field.name] = [...acValues, ...csvValues]
+ delete merged[csvFieldName]
+ })
+ return merged
+}
diff --git a/src/utils/get-cipp-formatting.js b/src/utils/get-cipp-formatting.js
index 604b3dea9325..30b4b2809596 100644
--- a/src/utils/get-cipp-formatting.js
+++ b/src/utils/get-cipp-formatting.js
@@ -43,6 +43,22 @@ const getCountryNameFromCode = (countryCode) => {
return country ? country.Name : countryCode
}
+// Shared so the card list and the extended-info drawer can label a portal link with the
+// same glyph the table cell uses.
+export const portalIcons = {
+ portal_m365: CogIcon,
+ portal_exchange: MailOutline,
+ portal_entra: UserIcon,
+ portal_teams: UsersIcon,
+ portal_azure: ServerIcon,
+ portal_intune: LaptopWindows,
+ portal_security: Shield,
+ portal_compliance: CompassCalibration,
+ portal_sharepoint: Description,
+ portal_platform: PrecisionManufacturing,
+ portal_bi: BarChart,
+}
+
export const getCippFormatting = (
data,
cellName,
@@ -63,20 +79,6 @@ export const getCippFormatting = (
)
}
- const portalIcons = {
- portal_m365: CogIcon,
- portal_exchange: MailOutline,
- portal_entra: UserIcon,
- portal_teams: UsersIcon,
- portal_azure: ServerIcon,
- portal_intune: LaptopWindows,
- portal_security: Shield,
- portal_compliance: CompassCalibration,
- portal_sharepoint: Description,
- portal_platform: PrecisionManufacturing,
- portal_bi: BarChart,
- }
-
// Create a helper function to render chips with CollapsibleChipList
const renderChipList = (items, maxItems = 4) => {
if (!Array.isArray(items) || items.length === 0) {
@@ -266,6 +268,9 @@ export const getCippFormatting = (
'NextAttemptUtc',
'LastErrorUtc',
'LastPolledUtc',
+ 'QueuedUtc', // Worker health job queue
+ 'StartedUtc', // Worker health job queue
+ 'CompletedUtc', // Worker health job queue
]
if (absoluteDateArray.includes(cellName)) {
if (data === null || data === undefined || data === '') {
@@ -274,9 +279,11 @@ export const getCippFormatting = (
const dt = parseCippDate(data)
if (isNaN(dt.getTime())) return isText ? '' : ''
if (dt.getTime() === 0) return isText ? '' : 'Never'
- // text mode: Date object so MRT sorts chronologically (toLocaleString for CSV export);
+ // text mode: Date object so MRT sorts chronologically — except when the caller can
+ // receive a rendered node ('both': off-canvas, card views) or explicitly wants a
+ // string (false: CSV export); a raw Date is not a valid React child.
// cell mode: long absolute string in the browser's locale + timezone.
- if (isText) return canReceive === false ? dt.toLocaleString() : dt
+ if (isText) return canReceive === 'both' || canReceive === false ? dt.toLocaleString() : dt
return dt.toLocaleString()
}
@@ -317,6 +324,7 @@ export const getCippFormatting = (
'requestDate', // App Consent Requests
'reviewedDate', // App Consent Requests
'GeneratedAt', // Report Builder
+ 'RecordedAt', // Container update history
'directTenantAuthDate', // Direct tenant service account
'ServiceAccountLastAuth', // Direct tenant service account
]
@@ -1051,6 +1059,20 @@ export const getCippFormatting = (
)
}
+ // handle role members
+ // Without this the CSV/PDF exports fall through to the generic object branch and emit raw
+ // JSON per member. The on-screen cell keeps rendering as the items button.
+ if (cellName === 'Members' && Array.isArray(data)) {
+ return isText ? (
+ data
+ .map((member) => member?.displayName || member?.userPrincipalName || member?.id)
+ .filter(Boolean)
+ .join(', ')
+ ) : (
+
+ )
+ }
+
// Handle assigned licenses
if (cellName === 'assignedLicenses') {
var translatedLicenses = getCippLicenseTranslation(data)
diff --git a/src/utils/get-filtered-portals.js b/src/utils/get-filtered-portals.js
new file mode 100644
index 000000000000..d4f00dff1ecd
--- /dev/null
+++ b/src/utils/get-filtered-portals.js
@@ -0,0 +1,37 @@
+import Portals from "../data/portals";
+
+// Which M365 portal links the user wants shown, resolved from user-specific settings
+// (preferred), tenant-level settings, or the all-on defaults. Pure so both the dashboard
+// menu and the mobile FAB sheet share one filter (and it stays unit-testable).
+export const getFilteredPortals = (settings) => {
+ const defaultLinks = {
+ M365_Portal: true,
+ Exchange_Portal: true,
+ Entra_Portal: true,
+ Teams_Portal: true,
+ Azure_Portal: true,
+ Intune_Portal: true,
+ SharePoint_Admin: true,
+ Security_Portal: true,
+ Compliance_Portal: true,
+ Power_Platform_Portal: true,
+ Power_BI_Portal: true,
+ };
+
+ let portalLinks;
+ if (settings?.UserSpecificSettings?.portalLinks) {
+ portalLinks = {
+ ...defaultLinks,
+ ...settings.UserSpecificSettings.portalLinks,
+ };
+ } else if (settings?.portalLinks) {
+ portalLinks = { ...defaultLinks, ...settings.portalLinks };
+ } else {
+ portalLinks = defaultLinks;
+ }
+
+ return Portals.filter((portal) => {
+ const settingKey = portal.name;
+ return settingKey ? portalLinks[settingKey] === true : true;
+ });
+};
diff --git a/src/utils/help-links.js b/src/utils/help-links.js
new file mode 100644
index 000000000000..8de5cb2ed923
--- /dev/null
+++ b/src/utils/help-links.js
@@ -0,0 +1,41 @@
+// Help/support destinations shared by CippSpeedDial (desktop FAB) and AccountPopover
+// (mobile, where the FAB corner belongs to page actions). One definition so the two
+// surfaces can't drift.
+
+export const getHelpLinks = (pathname = "") => [
+ {
+ id: "bug-report",
+ name: "Report Bug",
+ href: "https://github.com/CyberDrain/CIPP/issues/new?template=bug.yml",
+ },
+ {
+ id: "feature-request",
+ name: "Request Feature",
+ href: "https://github.com/CyberDrain/CIPP/issues/new?template=feature.yml",
+ },
+ {
+ id: "discord",
+ name: "Join the Discord!",
+ href: "https://discord.gg/cyberdrain",
+ },
+ {
+ id: "documentation",
+ name: "Check the Documentation",
+ href: `https://docs.cipp.app/user-documentation${pathname}`,
+ },
+];
+
+// Clears the TanStack Query cache (memory + the persisted localStorage copy) and hard-reloads.
+export const clearCippCache = (queryClient) => {
+ queryClient.clear();
+
+ if (typeof window !== "undefined") {
+ Object.keys(localStorage).forEach((key) => {
+ if (key.startsWith("REACT_QUERY_OFFLINE_CACHE")) {
+ localStorage.removeItem(key);
+ }
+ });
+ // Force refresh the page to bypass browser cache and reload JavaScript
+ window.location.reload(true);
+ }
+};
diff --git a/src/utils/impersonation.js b/src/utils/impersonation.js
new file mode 100644
index 000000000000..a9e7d9a75b4e
--- /dev/null
+++ b/src/utils/impersonation.js
@@ -0,0 +1,75 @@
+/**
+ * Role impersonation state (superadmin-only feature).
+ *
+ * Lives in its own localStorage key - NOT app.settings, which round-trips to the server
+ * via ExecUserSettings and races on init - so it is readable synchronously from
+ * non-React code (buildVersionedHeaders) and via useSyncExternalStore in components.
+ * The backend only honors the header for real superadmins, so this state can never
+ * grant privileges; it only narrows them.
+ */
+
+const KEY = 'cipp_impersonate_role'
+const listeners = new Set()
+const notify = () => listeners.forEach((listener) => listener())
+
+// localStorage throws in locked-down browsers - never let that break the app.
+export const getImpersonatedRole = () => {
+ if (typeof window === 'undefined') return null
+ try {
+ return window.localStorage.getItem(KEY) || null
+ } catch {
+ return null
+ }
+}
+
+export const subscribeImpersonation = (listener) => {
+ listeners.add(listener)
+ return () => listeners.delete(listener)
+}
+
+// Everything except authmecipp is persisted to localStorage (REACT_QUERY_OFFLINE_CACHE*),
+// so both transitions must clear the persisted cache and hard-reload or role-scoped data
+// from the other identity survives. Mirrors the "Clear Cache and Reload" speed-dial in
+// _app.js. Never use queryClient.cancelQueries() here (permanent-abort race).
+const clearCachesAndReload = (queryClient) => {
+ try {
+ queryClient?.clear()
+ } catch {
+ /* reload still gives a clean slate */
+ }
+ try {
+ Object.keys(window.localStorage)
+ .filter((key) => key.startsWith('REACT_QUERY_OFFLINE_CACHE'))
+ .forEach((key) => window.localStorage.removeItem(key))
+ } catch {
+ /* worst case: stale cache entries expire on their own */
+ }
+ window.location.reload()
+}
+
+export const enterImpersonation = (role, queryClient) => {
+ try {
+ window.localStorage.setItem(KEY, String(role).toLowerCase())
+ } catch {
+ return
+ }
+ notify()
+ clearCachesAndReload(queryClient)
+}
+
+export const exitImpersonation = (queryClient) => {
+ try {
+ window.localStorage.removeItem(KEY)
+ } catch {
+ /* fall through - reload clears in-memory state regardless */
+ }
+ notify()
+ clearCachesAndReload(queryClient)
+}
+
+// The Craft response cache keys on URL + params, not headers - impersonated GETs carry
+// this param so the two identities can never share a cached response.
+export const impersonationCacheParams = () => {
+ const role = getImpersonatedRole()
+ return role ? { _imp: role } : {}
+}
diff --git a/src/utils/is-cloud-pc-device.js b/src/utils/is-cloud-pc-device.js
new file mode 100644
index 000000000000..cda4c5259da4
--- /dev/null
+++ b/src/utils/is-cloud-pc-device.js
@@ -0,0 +1,13 @@
+// Windows 365 Cloud PCs never report BitLocker (isEncrypted stays false) although their disks
+// are platform-encrypted by Azure, so encryption reporting must not flag them as unencrypted.
+// Mirrors the backend Test-CIPPCloudPCDevice check: the cached CIPP marker, the documented
+// deviceType signal (chassisType kept in case the service starts emitting it), then the
+// model/manufacturer pair Windows 365 provisions.
+export const isCloudPcDevice = (device) =>
+ device?.isCloudPC === true ||
+ device?.deviceType === 'cloudPC' ||
+ device?.chassisType === 'cloudPC' ||
+ (typeof device?.model === 'string' &&
+ device.model.toLowerCase().startsWith('cloud pc') &&
+ typeof device?.manufacturer === 'string' &&
+ device.manufacturer.toLowerCase() === 'microsoft corporation')
diff --git a/src/utils/overlay-history.js b/src/utils/overlay-history.js
new file mode 100644
index 000000000000..c5d997fb9779
--- /dev/null
+++ b/src/utils/overlay-history.js
@@ -0,0 +1,123 @@
+/**
+ * Makes the phone's back gesture dismiss the topmost overlay instead of leaving the page.
+ *
+ * A back swipe IS history navigation, so the only way to intercept it is to own a history
+ * entry. An overlay that opts in pushes one entry at the SAME url — nothing visible
+ * changes — carrying a depth marker; swiping back pops that entry and we close the overlay
+ * rather than letting the router move.
+ *
+ * Two details keep this safe next to Next's pages router:
+ *
+ * 1. The pushed state CLONES the router's current state (__N/url/as/key) and only adds
+ * the marker. If the user navigates away with an overlay open, our entry is still a
+ * valid route entry, so returning to it renders that page instead of dead-ending on a
+ * state Next refuses to recognise.
+ * 2. router.beforePopState() suppresses Next's same-url re-render for pops that are ours.
+ * Without it, closing a drawer would emit route events and reset the scroll position —
+ * a long list would jump back to the top every time you dismissed a row.
+ *
+ * Module-level rather than per-component because the stack has to be shared: a back press
+ * must close the deepest open overlay, whoever rendered it.
+ */
+
+const MARKER = "__cippOverlay";
+
+// Entries mirror the history entries we pushed, deepest last.
+let stack = [];
+// The marker depth of the entry the browser is currently sitting on. Tracked here because
+// beforePopState runs after window.history has already moved, so the previous depth is
+// otherwise unknowable.
+let depth = 0;
+// history.back() calls WE made. The resulting popstate must not be mistaken for the user's.
+let selfPops = 0;
+let installed = false;
+
+const hasWindow = () => typeof window !== "undefined" && typeof window.history !== "undefined";
+
+const readDepth = () => {
+ if (!hasWindow()) return 0;
+ const value = window.history.state?.[MARKER];
+ return typeof value === "number" ? value : 0;
+};
+
+const handlePopState = () => {
+ const next = readDepth();
+ const wasSelfPop = selfPops > 0;
+ if (wasSelfPop) selfPops -= 1;
+ depth = next;
+
+ // Pop deepest-first: closing bottom-up would briefly leave an overlay covering one that
+ // is still open. Entries released by their own component are already gone from the stack,
+ // so a self-pop normally finds nothing here.
+ const dismissed = [];
+ while (stack.length > 0 && stack[stack.length - 1].depth > next) {
+ dismissed.push(stack.pop());
+ }
+ dismissed.forEach((entry) => {
+ if (entry.released) return;
+ entry.released = true;
+ entry.close?.();
+ });
+};
+
+/**
+ * Next calls this from its own popstate listener, before ours. Returning false means "the
+ * app handled this" and Next skips the route change entirely.
+ */
+const shouldNextHandle = (state) => {
+ if (selfPops > 0) return false;
+ if (stack.length === 0) return true;
+ // Not a pop out of an overlay entry — a forward move or an unrelated traversal.
+ if (readDepth() >= depth) return true;
+ // A real navigation that happens to jump past our entries still belongs to Next.
+ const top = stack[stack.length - 1];
+ if (state?.as && top.as && state.as !== top.as) return true;
+ return false;
+};
+
+export const installOverlayHistory = (router) => {
+ if (installed || !hasWindow()) return;
+ installed = true;
+ depth = readDepth();
+ window.addEventListener("popstate", handlePopState);
+ router?.beforePopState?.(shouldNextHandle);
+};
+
+export const pushOverlayEntry = (close) => {
+ if (!hasWindow()) return null;
+ const entry = {
+ depth: readDepth() + 1,
+ as: window.history.state?.as,
+ close,
+ released: false,
+ };
+ stack.push(entry);
+ depth = entry.depth;
+ window.history.pushState({ ...window.history.state, [MARKER]: entry.depth }, "");
+ return entry;
+};
+
+export const releaseOverlayEntry = (entry) => {
+ if (!entry || entry.released) return;
+ entry.released = true;
+ const index = stack.indexOf(entry);
+ if (index === -1) return;
+ const isTop = index === stack.length - 1;
+ stack.splice(index, 1);
+ // Only the entry the browser is actually sitting on can be popped. If something was
+ // pushed over ours — another overlay, or a route change while we were open — ours is
+ // buried: drop it and leave history alone rather than yanking the user backwards.
+ if (!isTop || !hasWindow() || readDepth() !== entry.depth) return;
+ selfPops += 1;
+ depth = entry.depth - 1;
+ window.history.back();
+};
+
+// Module state outlives a render tree, so tests need a way back to zero.
+export const resetOverlayHistory = () => {
+ if (hasWindow()) window.removeEventListener("popstate", handlePopState);
+ stack = [];
+ depth = 0;
+ selfPops = 0;
+ installed = false;
+};
diff --git a/src/utils/permission-rules.js b/src/utils/permission-rules.js
new file mode 100644
index 000000000000..56f8a6048a24
--- /dev/null
+++ b/src/utils/permission-rules.js
@@ -0,0 +1,180 @@
+/**
+ * Permission rule helpers for custom roles.
+ *
+ * Rules use the same include/exclude glob format as base roles (cipp-roles.json):
+ * patterns match against "Category.Object.Read|ReadWrite" strings, exclude wins.
+ * Matching mirrors PowerShell -like: * is the only wildcard, case-insensitive.
+ */
+
+const escapeRegex = (str) => str.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
+
+export const matchPattern = (pattern, value) => {
+ if (typeof pattern !== 'string' || typeof value !== 'string') return false
+ const regex = new RegExp(
+ `^${escapeRegex(pattern).replace(/\*/g, '.*')}$`,
+ 'i'
+ )
+ return regex.test(value)
+}
+
+// Flatten the ExecAPIPermissionList tree ({Cat: {Obj: {Read|ReadWrite: {...}}}})
+// into the sorted list of concrete permission strings.
+export const flattenPermissionTree = (apiPermissions) => {
+ const universe = []
+ if (!apiPermissions || typeof apiPermissions !== 'object') return universe
+ Object.keys(apiPermissions).forEach((cat) => {
+ Object.keys(apiPermissions[cat] || {}).forEach((obj) => {
+ Object.keys(apiPermissions[cat][obj] || {}).forEach((type) => {
+ universe.push(`${cat}.${obj}.${type}`)
+ })
+ })
+ })
+ return universe.sort()
+}
+
+const normalizeRuleList = (list) =>
+ (Array.isArray(list) ? list : [])
+ .map((entry) => (typeof entry === 'string' ? entry : entry?.value))
+ .filter((entry) => typeof entry === 'string' && entry.length > 0)
+
+/**
+ * Expand include/exclude rules over a permission universe.
+ * Returns the matched permissions plus per-pattern stats for the live preview:
+ * - includeCounts: pattern -> total universe matches
+ * - excludeCounts: pattern -> included permissions this pattern removed
+ * - excludedBy: permission -> first exclude pattern that removed it
+ */
+export const expandRules = (rules, universe) => {
+ const include = normalizeRuleList(rules?.Include)
+ const exclude = normalizeRuleList(rules?.Exclude)
+ const includeCounts = {}
+ const excludeCounts = {}
+ const excludedBy = {}
+ include.forEach((pattern) => (includeCounts[pattern] = 0))
+ exclude.forEach((pattern) => (excludeCounts[pattern] = 0))
+
+ const matched = []
+ ;(universe || []).forEach((permission) => {
+ let included = false
+ include.forEach((pattern) => {
+ if (matchPattern(pattern, permission)) {
+ includeCounts[pattern] += 1
+ included = true
+ }
+ })
+ if (!included) return
+ const excludedByPattern = exclude.find((pattern) =>
+ matchPattern(pattern, permission)
+ )
+ if (excludedByPattern !== undefined) {
+ excludeCounts[excludedByPattern] += 1
+ excludedBy[permission] = excludedByPattern
+ return
+ }
+ matched.push(permission)
+ })
+
+ return { matched, includeCounts, excludeCounts, excludedBy }
+}
+
+/**
+ * Convert rules into the flat editor/storage map: { "CatObj": "Cat.Obj.None|Read|ReadWrite" }.
+ * ReadWrite beats Read; CIPP.Core is floored at Read (login breaks without it).
+ */
+export const rulesToFlatMap = (rules, apiPermissions) => {
+ const flat = {}
+ if (!apiPermissions || typeof apiPermissions !== 'object') return flat
+ const include = normalizeRuleList(rules?.Include)
+ const exclude = normalizeRuleList(rules?.Exclude)
+
+ const granted = (permission) =>
+ include.some((pattern) => matchPattern(pattern, permission)) &&
+ !exclude.some((pattern) => matchPattern(pattern, permission))
+
+ Object.keys(apiPermissions).forEach((cat) => {
+ Object.keys(apiPermissions[cat] || {}).forEach((obj) => {
+ let level = 'None'
+ if (granted(`${cat}.${obj}.ReadWrite`)) {
+ level = 'ReadWrite'
+ } else if (granted(`${cat}.${obj}.Read`)) {
+ level = 'Read'
+ }
+ if (cat === 'CIPP' && obj === 'Core' && level === 'None') {
+ level = 'Read'
+ }
+ flat[`${cat}${obj}`] = `${cat}.${obj}.${level}`
+ })
+ })
+ return flat
+}
+
+// Convert the flat map back into concrete-string rules (the canonical storage
+// format for advanced-mode roles): Include = explicit non-None values.
+export const flatMapToRules = (flatMap) => {
+ const include = [
+ ...new Set(
+ Object.values(flatMap || {}).filter(
+ (value) =>
+ typeof value === 'string' &&
+ value.length > 0 &&
+ !value.endsWith('.None')
+ )
+ ),
+ ].sort()
+ return { Include: include, Exclude: [] }
+}
+
+// 1-3 dot-separated segments of letters/digits/wildcards, e.g. "*", "*.Read",
+// "Identity.User.*", "Identity.User.ReadWrite". Same grammar the backend enforces.
+export const validateRulePattern = (str) =>
+ typeof str === 'string' && /^[A-Za-z0-9*]+(\.[A-Za-z0-9*]+){0,2}$/.test(str)
+
+// Suggestion options for the rule autocompletes, grouped for CippAutocompleteGrouping.
+export const buildRuleSuggestions = (apiPermissions) => {
+ const suggestions = [
+ { label: '* (everything)', value: '*', category: 'Global' },
+ { label: '*.Read (all read-only)', value: '*.Read', category: 'Global' },
+ {
+ label: '*.ReadWrite (all read/write)',
+ value: '*.ReadWrite',
+ category: 'Global',
+ },
+ ]
+ if (!apiPermissions || typeof apiPermissions !== 'object') return suggestions
+ Object.keys(apiPermissions)
+ .sort()
+ .forEach((cat) => {
+ suggestions.push({
+ label: `${cat}.* (entire category)`,
+ value: `${cat}.*`,
+ category: cat,
+ })
+ suggestions.push({
+ label: `${cat}.*.Read`,
+ value: `${cat}.*.Read`,
+ category: cat,
+ })
+ suggestions.push({
+ label: `${cat}.*.ReadWrite`,
+ value: `${cat}.*.ReadWrite`,
+ category: cat,
+ })
+ Object.keys(apiPermissions[cat] || {})
+ .sort()
+ .forEach((obj) => {
+ suggestions.push({
+ label: `${cat}.${obj}.*`,
+ value: `${cat}.${obj}.*`,
+ category: cat,
+ })
+ Object.keys(apiPermissions[cat][obj] || {}).forEach((type) => {
+ suggestions.push({
+ label: `${cat}.${obj}.${type}`,
+ value: `${cat}.${obj}.${type}`,
+ category: cat,
+ })
+ })
+ })
+ })
+ return suggestions
+}
diff --git a/src/utils/render-url-value.jsx b/src/utils/render-url-value.jsx
new file mode 100644
index 000000000000..6757b9aa6f5d
--- /dev/null
+++ b/src/utils/render-url-value.jsx
@@ -0,0 +1,58 @@
+import { Link, SvgIcon } from "@mui/material";
+import OpenInNew from "@mui/icons-material/OpenInNew";
+import { portalIcons } from "./get-cipp-formatting";
+
+const ABSOLUTE_URL = /^https?:\/\//i;
+// A bare host like "contoso-admin.sharepoint.com" — a portal link often arrives without
+// its scheme, which would otherwise be resolved against the CIPP origin.
+const HOST_LIKE = /^[\w-]+(\.[\w-]+)+(\/|$)/;
+
+/**
+ * A tappable, self-describing link for a URL-valued field.
+ *
+ * Table cells render portals as a bare icon, which reads fine under a narrow column header
+ * and not at all once the same value appears in a card or a labelled property list.
+ * Returns null when the value isn't linkable, so callers fall back to normal formatting.
+ */
+export const renderUrlValue = (value, field = "") => {
+ if (typeof value !== "string" || !value.trim()) return null;
+
+ const isPortal = field.startsWith("portal_");
+ const trimmed = value.trim();
+ if (!isPortal && !ABSOLUTE_URL.test(trimmed)) return null;
+
+ const href = ABSOLUTE_URL.test(trimmed)
+ ? trimmed
+ : HOST_LIKE.test(trimmed)
+ ? `https://${trimmed}`
+ : trimmed;
+
+ const PortalIcon = portalIcons[field];
+
+ return (
+ event.stopPropagation()}
+ sx={{
+ display: "inline-flex",
+ alignItems: "center",
+ gap: 0.5,
+ minWidth: 0,
+ overflowWrap: "anywhere",
+ }}
+ >
+ {PortalIcon && (
+
+
+
+ )}
+ {isPortal ? "Open portal" : trimmed}
+
+
+
+
+ );
+};
diff --git a/src/utils/resolve-row-templates.js b/src/utils/resolve-row-templates.js
new file mode 100644
index 000000000000..7a3656c7dbdc
--- /dev/null
+++ b/src/utils/resolve-row-templates.js
@@ -0,0 +1,93 @@
+const TEMPLATE = /\[([^\]]+)\]/g
+
+/**
+ * Resolve a dotted path against an object. Missing segments yield undefined.
+ */
+export const getNestedValue = (source, path) => {
+ if (source === undefined || source === null) {
+ return undefined
+ }
+ if (!path) {
+ return source
+ }
+
+ return path.split('.').reduce((acc, key) => {
+ if (acc === undefined || acc === null) {
+ return undefined
+ }
+ if (typeof acc !== 'object') {
+ return undefined
+ }
+ return acc[key]
+ }, source)
+}
+
+/**
+ * Nested-table action context: `parent` is the opening row. If the child already
+ * had `parent` (API data), chain it at `parent.parent` unless the opening row is
+ * itself nested and already owns that slot.
+ */
+export const attachParentRow = (row, parentRow) => {
+ if (!parentRow || row == null) {
+ return row
+ }
+ if (Array.isArray(row)) {
+ return row.map((item) => attachParentRow(item, parentRow))
+ }
+ if (row.parent === parentRow) {
+ return row
+ }
+
+ let nextParent = parentRow
+ if (row.parent !== undefined && parentRow.parent === undefined) {
+ nextParent = { ...parentRow, parent: row.parent }
+ }
+ return { ...row, parent: nextParent }
+}
+
+/**
+ * AllTenants convention used across CIPP: prefer the row (or nested parent) tenant.
+ */
+export const getRowTenant = (row, currentTenant) => {
+ if (currentTenant !== 'AllTenants') {
+ return currentTenant
+ }
+ const source = Array.isArray(row) ? row[0] : row
+ return (
+ source?.Tenant ||
+ source?.parent?.Tenant ||
+ source?.tenantFilter ||
+ source?.parent?.tenantFilter ||
+ currentTenant
+ )
+}
+
+const replaceTemplatesInString = (value, row) =>
+ value.replace(TEMPLATE, (_, key) => {
+ const resolved = getNestedValue(row, key)
+ if (resolved === undefined || resolved === null) {
+ return `[${key}]`
+ }
+ return String(resolved)
+ })
+
+/**
+ * Walk strings (and objects/arrays of them) and replace `[field]` / `[nested.path]`
+ * from `row`. Booleans, numbers, and null stay as-is.
+ */
+export const resolveRowTemplates = (value, row) => {
+ if (typeof value === 'string') {
+ return replaceTemplatesInString(value, row)
+ }
+ if (Array.isArray(value)) {
+ return value.map((item) => resolveRowTemplates(item, row))
+ }
+ if (value && typeof value === 'object') {
+ const next = {}
+ for (const key of Object.keys(value)) {
+ next[key] = resolveRowTemplates(value[key], row)
+ }
+ return next
+ }
+ return value
+}
diff --git a/src/utils/support-bundle.js b/src/utils/support-bundle.js
new file mode 100644
index 000000000000..421d7b4d0c04
--- /dev/null
+++ b/src/utils/support-bundle.js
@@ -0,0 +1,246 @@
+import axios from 'axios'
+
+// Captures the API traffic behind the current page for the speed dial's support-file
+// generator. The recorder is armed only while the support dialog is collecting: the dialog
+// forces every active (mounted) query to refetch, so everything the page reads flows
+// through axios inside the capture window and is recorded here — successes included, since
+// support usually needs to see what the page DID get alongside what failed.
+
+// One oversized Graph list page must not balloon the bundle into something the user
+// cannot email, so recorded bodies are capped and flagged instead of stored whole.
+const MAX_BODY_CHARS = 262144
+
+let armed = false
+let seq = 0
+let calls = []
+
+const serializeValue = (data, responseType) => {
+ if (data === null || data === undefined) return { value: null }
+ if (
+ responseType === 'blob' ||
+ (typeof Blob !== 'undefined' && data instanceof Blob)
+ ) {
+ return {
+ value: ``,
+ }
+ }
+ if (typeof FormData !== 'undefined' && data instanceof FormData) {
+ return { value: ' }>
- Child content
+import React from "react";
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { screen } from "@testing-library/react";
+import { renderWithProviders } from "../../test-utils";
+
+// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook
+const layoutState = vi.hoisted(() => ({ isMobile: false }));
+vi.mock("../../../src/hooks/use-breakpoint", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useIsMobileLayout: () => layoutState.isMobile,
+ useIsTabletLayout: () => false,
+}));
+
+const routerState = vi.hoisted(() => ({ push: vi.fn(), pathname: "/cipp/roles" }));
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: routerState.push }),
+ usePathname: () => routerState.pathname,
+ useSearchParams: () => new URLSearchParams(""),
+}));
+vi.mock("next/router", () => ({
+ useRouter: () => ({ push: routerState.push, back: vi.fn() }),
+}));
+
+// Stable identities: a fresh object per call re-renders forever (tests/mocks/api-call.js)
+const idle = vi.hoisted(() => ({
+ isSuccess: false,
+ isFetching: false,
+ isPending: false,
+ isError: false,
+ data: undefined,
+ mutate: () => {},
+ reset: () => {},
+ refetch: () => {},
+}));
+vi.mock("../../../src/api/ApiCall", () => ({
+ ApiGetCall: () => idle,
+ ApiPostCall: () => idle,
+ ApiGetCallWithPagination: () => ({ ...idle, fetchNextPage: () => {} }),
+}));
+
+import { TabbedLayout } from "../../../src/layouts/TabbedLayout";
+import CippPageCard from "../../../src/components/CippCards/CippPageCard";
+
+const tabOptions = [
+ { label: "CIPP Roles", path: "/cipp/roles" },
+ { label: "CIPP Users", path: "/cipp/users" },
+];
+
+const renderPage = (title) =>
+ renderWithProviders(
+
+
+ page content
- )
- expect(screen.getByText('Info bar content')).toBeInTheDocument()
- })
-})
+
+ );
+
+describe("CippPageCard title vs the mobile tab picker", () => {
+ beforeEach(() => {
+ layoutState.isMobile = false;
+ routerState.pathname = "/cipp/roles";
+ });
+
+ // The picker trigger wears the current tab's label in heading clothes right above the
+ // page header — a page titled the same printed "CIPP Roles" twice in a row on a phone.
+ it("stands its title down when the picker already says it", () => {
+ layoutState.isMobile = true;
+ renderPage("CIPP Roles");
+
+ // once: the picker trigger (whose label is itself an h6 — query the page h4 by level)
+ expect(screen.getAllByText("CIPP Roles")).toHaveLength(1);
+ expect(screen.getByRole("button", { name: /CIPP Roles switch view/i })).toBeInTheDocument();
+ expect(screen.queryByRole("heading", { level: 4, name: "CIPP Roles" })).not.toBeInTheDocument();
+ });
+
+ it("keeps a title the picker does not carry", () => {
+ layoutState.isMobile = true;
+ renderPage("Edit Role: limited");
+
+ expect(
+ screen.getByRole("heading", { level: 4, name: "Edit Role: limited" })
+ ).toBeInTheDocument();
+ });
+
+ it("keeps its title on desktop, where tabs look like navigation", () => {
+ renderPage("CIPP Roles");
+
+ expect(screen.getByRole("heading", { level: 4, name: "CIPP Roles" })).toBeInTheDocument();
+ });
+});
diff --git a/tests/components/CippCards/CippUniversalSearchV2.stories.jsx b/tests/components/CippCards/CippUniversalSearchV2.stories.jsx
new file mode 100644
index 000000000000..3111dfff96be
--- /dev/null
+++ b/tests/components/CippCards/CippUniversalSearchV2.stories.jsx
@@ -0,0 +1,62 @@
+import React from 'react'
+import { within, waitFor, expect } from 'storybook/test'
+import { CippUniversalSearchV2 } from '../../../src/components/CippCards/CippUniversalSearchV2'
+import { shrinkToPhoneViewport, growToDesktopViewport } from '../../viewport'
+
+export default {
+ title: 'Components/CippCards/CippUniversalSearchV2',
+ component: CippUniversalSearchV2,
+ tags: ['autodocs'],
+}
+
+// Desktop: the scope button, field and search button are one joined bordered control. The
+// theme defaults TextField to the filled variant, whose own rounded border ignores every
+// join rule (they target .MuiOutlinedInput-root) — which once rendered the scope button and
+// field as two separate boxes.
+export const JoinedControlOnDesktop = {
+ render: () => (
+
+ ),
+ play: async ({ canvasElement, step }) => {
+ const onDesktop = await growToDesktopViewport()
+ if (!onDesktop) return
+ const canvas = within(canvasElement)
+
+ await step('the scope button and the field share one border, no gap', async () => {
+ const scope = canvas.getByRole('button', { name: /pages/i })
+ const field = canvasElement.querySelector('.MuiOutlinedInput-root')
+ await waitFor(() => {
+ expect(field).not.toBeNull()
+ const gap = field.getBoundingClientRect().left - scope.getBoundingClientRect().right
+ expect(Math.abs(gap)).toBeLessThanOrEqual(1)
+ expect(getComputedStyle(field).borderTopLeftRadius).toBe('0px')
+ expect(getComputedStyle(scope).borderTopRightRadius).toBe('0px')
+ })
+ })
+ },
+}
+
+// Phones: no scope button in the group — the field spans the row and each scope is a chip,
+// one tap away, so entity search has a direct entry point.
+export const ScopeChipsAtPhoneWidth = {
+ render: () => (
+
+ ),
+ play: async ({ canvasElement, step }) => {
+ const onAPhone = await shrinkToPhoneViewport()
+ if (!onAPhone) return
+ const canvas = within(canvasElement)
+
+ await step('the field takes the full row and every scope is a visible chip', async () => {
+ const field = canvasElement.querySelector('.MuiOutlinedInput-root')
+ await waitFor(() => {
+ expect(field).not.toBeNull()
+ for (const label of ['Users', 'Groups', 'Applications', 'Licenses', 'BitLocker', 'Pages']) {
+ expect(canvas.getByText(label)).toBeInTheDocument()
+ }
+ })
+ const host = canvasElement
+ expect(host.scrollWidth).toBeLessThanOrEqual(host.clientWidth)
+ })
+ },
+}
diff --git a/tests/components/CippCards/CippUniversalSearchV2.test.jsx b/tests/components/CippCards/CippUniversalSearchV2.test.jsx
new file mode 100644
index 000000000000..2f6cdc92f3b6
--- /dev/null
+++ b/tests/components/CippCards/CippUniversalSearchV2.test.jsx
@@ -0,0 +1,125 @@
+import React from 'react'
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { renderWithProviders } from '../../test-utils'
+
+// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook
+const layoutState = vi.hoisted(() => ({ isMobile: false }))
+vi.mock('../../../src/hooks/use-breakpoint', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useIsMobileLayout: () => layoutState.isMobile,
+}))
+
+const bookmarkState = vi.hoisted(() => ({ bookmarks: [] }))
+vi.mock('../../../src/hooks/use-user-bookmarks', () => ({
+ useUserBookmarks: () => ({ bookmarks: bookmarkState.bookmarks, setBookmarks: () => {} }),
+}))
+
+const idle = vi.hoisted(() => ({
+ isSuccess: false,
+ isFetching: false,
+ isLoading: false,
+ isError: false,
+ data: undefined,
+ refetch: () => {},
+}))
+vi.mock('../../../src/api/ApiCall', () => ({
+ ApiGetCall: () => idle,
+ ApiPostCall: () => idle,
+ ApiGetCallWithPagination: () => ({ ...idle, fetchNextPage: () => {} }),
+}))
+
+const routerState = vi.hoisted(() => ({ push: vi.fn() }))
+vi.mock('next/router', () => ({
+ useRouter: () => ({
+ pathname: '/',
+ query: {},
+ isReady: true,
+ push: routerState.push,
+ events: { on: () => {}, off: () => {} },
+ }),
+}))
+
+vi.mock('../../../src/hooks/use-permissions', () => ({
+ // the page index filters by permission; 'Identity.User.Read' satisfies the config's
+ // 'Identity.User.*' requirement so the Users pages exist to be found
+ usePermissions: () => ({ userPermissions: ['Identity.User.Read'], userRoles: ['superadmin'] }),
+}))
+
+import { CippUniversalSearchV2 } from '../../../src/components/CippCards/CippUniversalSearchV2'
+
+describe('CippUniversalSearchV2 mobile layout', () => {
+ beforeEach(() => {
+ layoutState.isMobile = false
+ bookmarkState.bookmarks = []
+ routerState.push = vi.fn()
+ })
+
+ it('keeps the scope dropdown on desktop, no chips', () => {
+ renderWithProviders( )
+ expect(screen.getByRole('button', { name: /pages/i })).toBeInTheDocument()
+ expect(screen.queryByText('Users', { selector: '.MuiChip-label' })).not.toBeInTheDocument()
+ })
+
+ // The desktop scope dropdown cost two taps, and entity search had no direct mobile entry
+ // point at all — one chip per scope closes that.
+ it('renders one chip per scope on mobile and switches with a tap', async () => {
+ layoutState.isMobile = true
+ const user = userEvent.setup()
+ renderWithProviders( )
+
+ for (const label of ['Users', 'Groups', 'Applications', 'Licenses', 'BitLocker', 'Pages']) {
+ expect(screen.getByText(label, { selector: '.MuiChip-label' })).toBeInTheDocument()
+ }
+
+ await user.click(screen.getByText('Users', { selector: '.MuiChip-label' }))
+ expect(screen.getByPlaceholderText(/search users/i)).toBeInTheDocument()
+
+ // BitLocker reveals its lookup sub-choice as a second chip row
+ await user.click(screen.getByText('BitLocker', { selector: '.MuiChip-label' }))
+ expect(screen.getByText('Key ID', { selector: '.MuiChip-label' })).toBeInTheDocument()
+ expect(screen.getByText('Device ID', { selector: '.MuiChip-label' })).toBeInTheDocument()
+ })
+
+ it('fills the empty state with bookmarks that navigate and close', async () => {
+ layoutState.isMobile = true
+ bookmarkState.bookmarks = [
+ { label: 'GDAP Relationships', path: '/tenant/gdap-management/relationships', category: 'Tenant' },
+ ]
+ const onConfirm = vi.fn()
+ const user = userEvent.setup()
+ renderWithProviders( )
+
+ expect(screen.getByText('Bookmarks')).toBeInTheDocument()
+ await user.click(screen.getByText('GDAP Relationships'))
+ expect(routerState.push).toHaveBeenCalledWith('/tenant/gdap-management/relationships')
+ expect(onConfirm).toHaveBeenCalled()
+ })
+
+ // userEvent.click fires mousedown -> click; the outside-click closer ran on mousedown,
+ // unmounted the row, and the click landed on nothing — results vanished, no navigation.
+ it('navigates when a page result is tapped, instead of just closing', async () => {
+ layoutState.isMobile = true
+ const onConfirm = vi.fn()
+ const user = userEvent.setup()
+ renderWithProviders( )
+
+ await user.type(screen.getByPlaceholderText(/search pages/i), 'users')
+ const result = await screen.findAllByRole('menuitem')
+ await user.click(result[0])
+
+ expect(routerState.push).toHaveBeenCalled()
+ expect(onConfirm).toHaveBeenCalled()
+ })
+
+ it('renders page results in flow on mobile, not in a portal panel', async () => {
+ layoutState.isMobile = true
+ const user = userEvent.setup()
+ renderWithProviders( )
+
+ await user.type(screen.getByPlaceholderText(/search pages/i), 'users')
+ // the floating panel marks itself; in-flow results must not
+ expect(document.querySelector('[data-dropdown-portal]')).toBeNull()
+ })
+})
diff --git a/tests/components/CippCards/mobile-overflow.stories.jsx b/tests/components/CippCards/mobile-overflow.stories.jsx
new file mode 100644
index 000000000000..4809afc86ebb
--- /dev/null
+++ b/tests/components/CippCards/mobile-overflow.stories.jsx
@@ -0,0 +1,167 @@
+import React, { useRef, useState, useEffect } from 'react'
+import { Box, Card } from '@mui/material'
+import { within, waitFor, expect } from 'storybook/test'
+import { CippChartCard } from '../../../src/components/CippCards/CippChartCard'
+import { CippImageCard } from '../../../src/components/CippCards/CippImageCard'
+import { CippVariableAutocomplete } from '../../../src/components/CippComponents/CippVariableAutocomplete'
+import { PermissionTable } from '../../../src/components/CippSettings/CippSSOSettings'
+import { shrinkToPhoneViewport } from '../../viewport'
+
+/**
+ * Phone-width overflow checks for the shared components the mobile audit found spilling out
+ * of the viewport. Each story renders the component with the hostile content class that
+ * broke it — API free text, fixed-width caps — and asserts the page body gained no sideways
+ * scroll at 390px.
+ */
+export default {
+ title: 'Components/MobileOverflow',
+ tags: ['autodocs'],
+}
+
+const noBodyOverflow = () => {
+ const doc = document.documentElement
+ expect(doc.scrollWidth).toBeLessThanOrEqual(doc.clientWidth)
+}
+
+// Legend labels are API free text — recipient addresses, SharePoint library URLs. Without
+// minWidth: 0 flexbox refuses to shrink them and the rows push out of the card.
+export const ChartLegendWithUrlLabels = {
+ render: () => (
+
+ ),
+ play: async ({ canvasElement }) => {
+ const onAPhone = await shrinkToPhoneViewport()
+ if (!onAPhone) return
+ const canvas = within(canvasElement)
+ const label = await canvas.findByText(/finance/, { exact: false })
+ await waitFor(() => {
+ // the count is the row's right-hand cell: an unshrinkable label pushed it past the
+ // card's clipped edge, where MUI's overflow: hidden ate it without a trace
+ const card = label.closest('.MuiCard-root')
+ const count = canvas.getByText('12')
+ expect(count.getBoundingClientRect().right).toBeLessThanOrEqual(
+ card.getBoundingClientRect().right + 1
+ )
+ noBodyOverflow()
+ })
+ },
+}
+
+// The headline/illustration pair had no breakpoint and no minWidth: 0 — at 390px the text
+// column collapsed against the image's intrinsic width. This is the AllTenants interstitial.
+export const ImageCardAtPhoneWidth = {
+ render: () => (
+
+ ),
+ play: async ({ canvasElement }) => {
+ const onAPhone = await shrinkToPhoneViewport()
+ if (!onAPhone) return
+ const canvas = within(canvasElement)
+ const title = await canvas.findByText(/does not support/, { exact: false })
+ await waitFor(() => {
+ // stacked, not squeezed: the old row layout let flexbox settle the fight by
+ // collapsing the illustration to zero width — "no overflow" while showing nothing
+ const img = canvasElement.querySelector('img')
+ const imgBox = img.getBoundingClientRect()
+ expect(imgBox.top).toBeGreaterThanOrEqual(title.getBoundingClientRect().bottom)
+ expect(imgBox.width).toBeGreaterThanOrEqual(200)
+ noBodyOverflow()
+ })
+ },
+}
+
+const LONG_DESCRIPTION =
+ 'The primary tenant domain name used for routing and identification across all portals, ' +
+ 'reports and scheduled tasks — substituted at execution time from the tenant record.'
+
+const PopperHost = () => {
+ const anchorRef = useRef(null)
+ const [anchorEl, setAnchorEl] = useState(null)
+ useEffect(() => setAnchorEl(anchorRef.current), [])
+ return (
+
+
+ {anchorEl && (
+ {}}
+ onSelect={() => {}}
+ customVariables={[
+ { variable: 'tenantfilter', description: LONG_DESCRIPTION },
+ { variable: 'defaultdomainname', description: LONG_DESCRIPTION },
+ ]}
+ />
+ )}
+
+ )
+}
+
+// Sentinel, not a repro: in this browser the absolutely-positioned Paper shrink-to-fits
+// inside the viewport even pre-fix, so this story also passed before the clamp. It stands
+// guard against a future fixed `width` here. The popper is portaled, so the assertion
+// measures against the viewport, not the canvas.
+export const VariablePopperStaysOnScreen = {
+ render: () => ,
+ play: async () => {
+ const onAPhone = await shrinkToPhoneViewport()
+ if (!onAPhone) return
+ await waitFor(() => {
+ const paper = document.querySelector('[data-cipp-autocomplete="true"]')
+ expect(paper).not.toBeNull()
+ const { right, left } = paper.getBoundingClientRect()
+ expect(left).toBeGreaterThanOrEqual(0)
+ expect(right).toBeLessThanOrEqual(document.documentElement.clientWidth)
+ })
+ noBodyOverflow()
+ },
+}
+
+// Sentinel: in this browser the longest name happens to fit a full-width card even without
+// the fix (the audited clip came from the settings page's narrower column and other font
+// metrics). Guards the invariant that matters — the permission being consented to is
+// readable inside the card, whatever this table is later wrapped in.
+export const SsoPermissionTableReadable = {
+ render: () => (
+
+
+
+ ),
+ play: async ({ canvasElement }) => {
+ const onAPhone = await shrinkToPhoneViewport()
+ if (!onAPhone) return
+ const canvas = within(canvasElement)
+ const name = await canvas.findByText(/ApplicationConfiguration/, { exact: false })
+ await waitFor(() => {
+ // reachable: the name's box ends inside the card, not under its clipped edge
+ const card = name.closest('.MuiCard-root')
+ expect(name.getBoundingClientRect().right).toBeLessThanOrEqual(
+ card.getBoundingClientRect().right + 1
+ )
+ noBodyOverflow()
+ })
+ },
+}
diff --git a/tests/components/CippComponents/CIPPM365OAuthButton.test.jsx b/tests/components/CippComponents/CIPPM365OAuthButton.test.jsx
index 5072ce5c1888..86fa667f867a 100644
--- a/tests/components/CippComponents/CIPPM365OAuthButton.test.jsx
+++ b/tests/components/CippComponents/CIPPM365OAuthButton.test.jsx
@@ -42,8 +42,16 @@ describe('CIPPM365OAuthButton popup flow', () => {
MockBroadcastChannel.instances.length = 0
api.get = getResult({ data: { applicationId: APP_ID } })
openSpy = vi.spyOn(window, 'open')
+ // The PKCE S256 challenge awaits a real digest, which settles on the event loop
+ // rather than the microtask queue and so cannot be flushed under fake timers.
+ // A resolved stub keeps the popup setup that follows it deterministic.
+ vi.spyOn(globalThis.crypto.subtle, 'digest').mockResolvedValue(new Uint8Array(32).buffer)
})
+ // Everything after the digest - the BroadcastChannel and the popup watcher - is set up
+ // in a microtask, so tests touching those have to let the click settle first.
+ const settleAuthStart = () => act(async () => {})
+
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
@@ -63,7 +71,7 @@ describe('CIPPM365OAuthButton popup flow', () => {
expect(screen.getByRole('button', { name: 'Login with Microsoft' })).toBeEnabled()
})
- it('re-enables the button shortly after the sign-in window is closed without a result', () => {
+ it('re-enables the button shortly after the sign-in window is closed without a result', async () => {
const popup = { closed: false, close: vi.fn() }
openSpy.mockReturnValue(popup)
const onAuthError = vi.fn()
@@ -71,6 +79,7 @@ describe('CIPPM365OAuthButton popup flow', () => {
fireEvent.click(authButton())
expect(screen.getByRole('button', { name: /Authenticating/ })).toBeDisabled()
+ await settleAuthStart()
popup.closed = true
// 1s watcher tick spots the closed window, then the 2s grace period elapses
@@ -86,12 +95,13 @@ describe('CIPPM365OAuthButton popup flow', () => {
expect(screen.getByRole('button', { name: 'Login with Microsoft' })).toBeEnabled()
})
- it('does not report a cancellation when a result arrived before the popup closed', () => {
+ it('does not report a cancellation when a result arrived before the popup closed', async () => {
const popup = { closed: false, close: vi.fn() }
openSpy.mockReturnValue(popup)
renderWithTheme( )
fireEvent.click(authButton())
+ await settleAuthStart()
// the /authredirect callback posts its result, then the popup closes itself
act(() => {
@@ -114,12 +124,13 @@ describe('CIPPM365OAuthButton popup flow', () => {
expect(screen.getByRole('button', { name: 'Login with Microsoft' })).toBeEnabled()
})
- it('cleans up the popup watcher when a result arrives', () => {
+ it('cleans up the popup watcher when a result arrives', async () => {
const popup = { closed: false, close: vi.fn() }
openSpy.mockReturnValue(popup)
renderWithTheme( )
fireEvent.click(authButton())
+ await settleAuthStart()
act(() => {
lastChannel().onmessage({
data: { type: 'auth_error', error: 'access_denied', errorDescription: 'cancelled' },
@@ -130,4 +141,119 @@ describe('CIPPM365OAuthButton popup flow', () => {
// with the watcher cleared, no timers remain to fire popup_closed later
expect(vi.getTimerCount()).toBe(0)
})
+
+ it('cancels the pending close check when a result lands during the grace period', async () => {
+ const popup = { closed: false, close: vi.fn() }
+ openSpy.mockReturnValue(popup)
+ renderWithTheme( )
+
+ fireEvent.click(authButton())
+ await settleAuthStart()
+
+ // the callback closes the popup first, so the watcher schedules its grace check...
+ popup.closed = true
+ act(() => {
+ vi.advanceTimersByTime(1000)
+ })
+ // ...and the result lands inside that window
+ act(() => {
+ lastChannel().onmessage({
+ data: { type: 'auth_error', error: 'access_denied', errorDescription: 'cancelled' },
+ })
+ })
+
+ // nothing left pending that could fire against a subsequent attempt
+ expect(vi.getTimerCount()).toBe(0)
+
+ act(() => {
+ vi.advanceTimersByTime(5000)
+ })
+ expect(screen.getByText(/Authentication Error: access_denied/)).toBeInTheDocument()
+ expect(screen.queryByText(/sign-in window was closed/)).not.toBeInTheDocument()
+ })
+})
+
+describe('CIPPM365OAuthButton device code flow', () => {
+ let openSpy
+
+ beforeEach(() => {
+ vi.useFakeTimers()
+ api.get = getResult({ data: { applicationId: APP_ID } })
+ openSpy = vi.spyOn(window, 'open')
+ // keep the poll pending so the flow stays mid-authentication
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ status: 'pending', error: 'authorization_pending' }),
+ })
+ )
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ vi.unstubAllGlobals()
+ vi.restoreAllMocks()
+ })
+
+ const codeResponse = (userCode, deviceCode) => ({
+ ok: true,
+ json: async () => ({
+ user_code: userCode,
+ device_code: deviceCode,
+ expires_in: 900,
+ interval: 5,
+ }),
+ })
+
+ const pendingResponse = {
+ ok: true,
+ json: async () => ({ status: 'pending', error: 'authorization_pending' }),
+ }
+
+ it('offers a fresh code instead of locking up when the sign-in window is closed', async () => {
+ const popup = { closed: false, close: vi.fn() }
+ openSpy.mockReturnValue(popup)
+ global.fetch = vi.fn().mockResolvedValue(codeResponse('FHA953X4X', 'dev-code-1'))
+
+ renderWithTheme( )
+
+ // first click retrieves the device code
+ fireEvent.click(screen.getByRole('button', { name: /Login with Microsoft/ }))
+ await act(async () => {})
+
+ // second click opens the popup and starts polling
+ global.fetch = vi.fn().mockResolvedValue(pendingResponse)
+ fireEvent.click(screen.getByRole('button', { name: /Authenticate with Code/ }))
+ await act(async () => {})
+ expect(screen.getByRole('button', { name: /Authenticating/ })).toBeDisabled()
+
+ // the user closes the sign-in window
+ popup.closed = true
+ await act(async () => {
+ vi.advanceTimersByTime(1000)
+ })
+
+ const restart = screen.getByRole('button', { name: /Start over with a new code/ })
+ expect(restart).toBeEnabled()
+ // the copy must not promise that the old code can be reused - it is consumed once entered
+ expect(screen.getByText(/cannot be used again/)).toBeInTheDocument()
+
+ // starting over requests a new code and retires the old poll
+ global.fetch = vi.fn().mockResolvedValue(codeResponse('NEWCODE99', 'dev-code-2'))
+ fireEvent.click(restart)
+ await act(async () => {})
+
+ expect(screen.getByText('NEWCODE99')).toBeInTheDocument()
+
+ // the superseded poll must not keep hitting the old device code
+ global.fetch.mockClear()
+ await act(async () => {
+ vi.advanceTimersByTime(30000)
+ })
+ const polledOldCode = global.fetch.mock.calls.some(([url]) =>
+ String(url).includes('dev-code-1')
+ )
+ expect(polledOldCode).toBe(false)
+ })
})
diff --git a/tests/components/CippComponents/CippAddUserDrawer.test.jsx b/tests/components/CippComponents/CippAddUserDrawer.test.jsx
new file mode 100644
index 000000000000..29b6fca9dd6e
--- /dev/null
+++ b/tests/components/CippComponents/CippAddUserDrawer.test.jsx
@@ -0,0 +1,204 @@
+import React, { useReducer } from 'react'
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { renderWithProviders, settingsWith } from '../../test-utils'
+import { CippAddUserDrawer } from '../../../src/components/CippComponents/CippAddUserDrawer'
+import { ApiGetCall, ApiPostCall, ApiGetCallWithPagination } from '../../../src/api/ApiCall'
+
+vi.mock('../../../src/api/ApiCall', () => ({
+ ApiGetCall: vi.fn(),
+ ApiPostCall: vi.fn(),
+ ApiGetCallWithPagination: vi.fn(),
+}))
+
+// The user pickers and the license selector pull in the data-table stack and the 2.2 MB license
+// dataset; none of them take part in the create-another-user flow, so they are stubbed. The
+// domain selector stays real - its auto-preselect is central to the bug under test.
+vi.mock('../../../src/components/CippComponents/CippFormUserSelector', () => ({
+ CippFormUserSelector: () =>
,
+ default: () =>
,
+}))
+vi.mock('../../../src/components/CippComponents/CippFormLicenseSelector', () => ({
+ CippFormLicenseSelector: () =>
,
+ default: () =>
,
+}))
+vi.mock('../../../src/components/CippComponents/CippApiResults', () => ({
+ CippApiResults: () => null,
+}))
+// CippFormComponent statically imports the data-table stack for its cippDataTable case;
+// nothing in this drawer uses it, but importing it is enough to exhaust the test worker.
+vi.mock('../../../src/components/CippTable/CippDataTable', () => ({
+ CippDataTable: () =>
,
+ default: () =>
,
+}))
+// CippAutoComplete statically imports CippJsonView for its option-preview offcanvas, which
+// drags in the formatting/code-block/Intune-definition graph - another worker-killer this
+// flow never renders.
+vi.mock('../../../src/components/CippFormPages/CippJSONView', () => ({
+ default: () => null,
+}))
+// The real drawer shell drags in the property-card/formatting graph, which this test does
+// not exercise. The stub keeps the essential contract: content + footer render only while
+// the drawer is open.
+vi.mock('../../../src/components/CippComponents/CippOffCanvas', () => ({
+ CippOffCanvas: ({ visible, children, footer }) =>
+ visible ? (
+
+ {children}
+ {footer}
+
+ ) : null,
+}))
+
+const idleGet = { isSuccess: false, isFetching: false, isError: false, data: undefined, refetch: vi.fn() }
+const okGet = (data) => ({ isSuccess: true, isFetching: false, isError: false, data, refetch: vi.fn() })
+
+// built once: CippAutoComplete's option mapping keys on data identity, a fresh literal per call never settles
+const userDefaults = okGet([])
+const extensionsConfig = okGet({})
+const groups = okGet([])
+const customDataMappings = okGet({ Results: [] })
+const userGroups = okGet([])
+const tenantDomains = {
+ isSuccess: true,
+ isFetching: false,
+ isError: false,
+ data: {
+ pages: [
+ {
+ Results: [
+ { id: 'testdomain.com', isDefault: true, isInitial: false, isVerified: true },
+ { id: 'other.com', isDefault: false, isInitial: false, isVerified: true },
+ ],
+ },
+ ],
+ },
+ fetchNextPage: vi.fn(),
+ refetch: vi.fn(),
+}
+const idlePaginated = { ...idleGet, fetchNextPage: vi.fn() }
+
+// Mutable state backing the ApiPostCall mock: flipping it and re-rendering imitates the
+// react-query mutation lifecycle (idle -> pending -> success) the drawer sees in production.
+let postState
+let mutateSpy
+
+function mockApis() {
+ ApiGetCall.mockImplementation(({ url }) => {
+ if (url.startsWith('/api/ListNewUserDefaults')) return userDefaults
+ if (url.startsWith('/api/ListExtensionsConfig')) return extensionsConfig
+ if (url.startsWith('/api/ListGroups')) return groups
+ if (url.startsWith('/api/ListCustomDataMappings')) return customDataMappings
+ if (url.startsWith('/api/ListUserGroups')) return userGroups
+ return idleGet
+ })
+ ApiGetCallWithPagination.mockImplementation(({ url }) =>
+ url === '/api/ListGraphRequest' ? tenantDomains : idlePaginated
+ )
+ ApiPostCall.mockImplementation(() => ({ ...postState, mutate: mutateSpy }))
+}
+
+// Buttons that force a re-render after mutating postState stand in for react-query pushing new
+// mutation state into the drawer.
+function Harness() {
+ const [, force] = useReducer((x) => x + 1, 0)
+ return (
+ <>
+ {
+ postState.isPending = true
+ force()
+ }}
+ >
+ flip-pending
+
+ {
+ postState.isPending = false
+ postState.isSuccess = true
+ force()
+ }}
+ >
+ flip-success
+
+
+ >
+ )
+}
+
+const getDomainInput = () =>
+ screen.getByLabelText(/Primary Domain name/i, { selector: 'input' })
+
+const fillRequiredFields = async (user, { displayName, username }) => {
+ const displayNameInput = screen.getByLabelText(/Display Name/i, { selector: 'input' })
+ await user.clear(displayNameInput)
+ await user.type(displayNameInput, displayName)
+ const usernameInput = screen.getByLabelText(/^Username/i, { selector: 'input' })
+ await user.clear(usernameInput)
+ await user.type(usernameInput, username)
+}
+
+describe('CippAddUserDrawer - create another user without a page refresh (issue #309)', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ postState = { isPending: false, isSuccess: false, isError: false }
+ mutateSpy = vi.fn()
+ mockApis()
+ })
+
+ it('re-enables the Create button for a second user after the first succeeds', async () => {
+ const user = userEvent.setup()
+ renderWithProviders( , {
+ settings: settingsWith({ usageLocation: { value: 'US', label: 'United States' } }),
+ })
+
+ await user.click(screen.getByRole('button', { name: 'Add User' }))
+
+ // First user: the domain selector auto-picks the tenant default domain
+ await waitFor(() => {
+ expect(getDomainInput()).toHaveValue('testdomain.com')
+ })
+ await fillRequiredFields(user, { displayName: 'First User', username: 'first.user' })
+
+ const createButton = screen.getByRole('button', { name: 'Create User' })
+ await waitFor(() => {
+ expect(createButton).toBeEnabled()
+ })
+ await user.click(createButton)
+ expect(mutateSpy).toHaveBeenCalledTimes(1)
+ expect(mutateSpy.mock.calls[0][0].data).toMatchObject({
+ displayName: 'First User',
+ username: 'first.user',
+ primDomain: { value: 'testdomain.com' },
+ })
+
+ // Simulate the mutation lifecycle so isSuccess transitions like it does in production
+ await user.click(screen.getByRole('button', { name: 'flip-pending' }))
+ await user.click(screen.getByRole('button', { name: 'flip-success' }))
+
+ // The drawer resets the form for the next user
+ const anotherButton = await screen.findByRole('button', { name: 'Create Another User' })
+
+ // The remounted domain selector must auto-pick the default domain again; without it the
+ // required primDomain stays silently empty and the button never re-enables (issue #309)
+ await waitFor(() => {
+ expect(getDomainInput()).toHaveValue('testdomain.com')
+ })
+
+ // Second user: complete all required fields again, exactly as the issue describes
+ await fillRequiredFields(user, { displayName: 'Second User', username: 'second.user' })
+
+ await waitFor(() => {
+ expect(anotherButton).toBeEnabled()
+ })
+ await user.click(anotherButton)
+ expect(mutateSpy).toHaveBeenCalledTimes(2)
+ expect(mutateSpy.mock.calls[1][0].data).toMatchObject({
+ displayName: 'Second User',
+ username: 'second.user',
+ primDomain: { value: 'testdomain.com' },
+ })
+ // two full form fills through userEvent.type
+ }, 15000)
+})
diff --git a/tests/components/CippComponents/CippApiDialog.test.jsx b/tests/components/CippComponents/CippApiDialog.test.jsx
index a1c94bfc7358..032d9d8f5a23 100644
--- a/tests/components/CippComponents/CippApiDialog.test.jsx
+++ b/tests/components/CippComponents/CippApiDialog.test.jsx
@@ -116,4 +116,38 @@ describe('CippApiDialog', () => {
await user.click(screen.getByRole('button', { name: 'Close' }))
expect(createDialog.handleClose).toHaveBeenCalledTimes(1)
})
+
+ it('resolves dotted parent maps on confirm', async () => {
+ const user = userEvent.setup()
+ renderDialog({
+ row: {
+ id: 'member-1',
+ displayName: 'Jane',
+ parent: { id: 'group-1', displayName: 'Finance' },
+ },
+ api: {
+ type: 'POST',
+ url: '/api/ExecWhatever',
+ data: { childId: 'id', parentId: 'parent.id' },
+ confirmText: 'Remove [displayName] from [parent.displayName]?',
+ },
+ })
+
+ expect(screen.getByText('Remove Jane from Finance?')).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'Confirm' }))
+
+ await waitFor(() => {
+ expect(apiState.mutate).toHaveBeenCalledTimes(1)
+ })
+ expect(apiState.mutate).toHaveBeenCalledWith({
+ url: '/api/ExecWhatever',
+ bulkRequest: false,
+ data: {
+ tenantFilter: 'testdomain.com',
+ childId: 'member-1',
+ parentId: 'group-1',
+ },
+ })
+ })
})
diff --git a/tests/components/CippComponents/CippAppPermissionBuilder.stories.jsx b/tests/components/CippComponents/CippAppPermissionBuilder.stories.jsx
new file mode 100644
index 000000000000..1fff3b9167d2
--- /dev/null
+++ b/tests/components/CippComponents/CippAppPermissionBuilder.stories.jsx
@@ -0,0 +1,93 @@
+import React from 'react'
+import { http, HttpResponse } from 'msw'
+import { within, expect, waitFor } from 'storybook/test'
+import { Box } from '@mui/material'
+import { useForm } from 'react-hook-form'
+import { shrinkToPhoneViewport } from '../../viewport'
+import CippAppPermissionBuilder from '../../../src/components/CippComponents/CippAppPermissionBuilder'
+
+// The summary row carries a 36-character app id, so this is where the overflow shows up.
+const graph = {
+ id: 'sp-graph',
+ appId: '00000003-0000-0000-c000-000000000000',
+ displayName: 'Microsoft Graph',
+ appRoles: [],
+ publishedPermissionScopes: [],
+}
+
+const servicePrincipals = { Metadata: { Success: true }, Results: [graph] }
+
+// The same route serves the list and, with ?Id=, one principal's detail — where Results is
+// an object rather than an array.
+const handlers = [
+ http.get('*/api/ExecServicePrincipals', ({ request }) => {
+ const id = new URL(request.url).searchParams.get('Id')
+ return HttpResponse.json(
+ id ? { Metadata: { Success: true }, Results: graph } : servicePrincipals
+ )
+ }),
+]
+
+const Harness = (props) => {
+ const formControl = useForm({ mode: 'onChange', defaultValues: { servicePrincipal: null } })
+ return (
+ {}}
+ updatePermissions={{ isPending: false, isSuccess: false, isError: false }}
+ currentPermissions={{
+ Permissions: {
+ '00000003-0000-0000-c000-000000000000': {
+ applicationPermissions: [{ id: '1', value: 'Application.ReadWrite.All' }],
+ delegatedPermissions: [{ id: '2', value: 'User.Read' }],
+ },
+ },
+ }}
+ {...props}
+ />
+ )
+}
+
+export default {
+ title: 'Components/CippComponents/CippAppPermissionBuilder',
+ component: CippAppPermissionBuilder,
+ parameters: { msw: { handlers } },
+}
+
+// jsdom has no layout engine, so overflow is invisible to the unit tests — this is the one
+// place a real browser can measure it. 390px is an iPhone 14/15 in portrait.
+//
+// The VIEWPORT has to shrink, not a wrapper: MUI's breakpoints are media queries, so a
+// 390px-wide Box inside a desktop-width iframe still renders every `md` branch.
+export const PhoneWidth = {
+ render: () => (
+
+
+
+ ),
+ play: async ({ canvasElement }) => {
+ const onAPhone = await shrinkToPhoneViewport()
+ const canvas = within(canvasElement)
+ await canvas.findByText('Microsoft Graph', {}, { timeout: 10000 })
+ // Opened in the Storybook app rather than the test runner: the layout is on show, but
+ // measuring it against a desktop-width iframe would only assert the wrong thing.
+ if (!onAPhone) return
+
+ // The app-id chip used to force the row wider than the phone, pushing the service
+ // principal's name off the left edge — the row scrolled, the name was unreachable.
+ await waitFor(() => {
+ const rows = canvasElement.querySelectorAll('.MuiAccordionSummary-root')
+ expect(rows.length).toBeGreaterThan(0)
+ rows.forEach((row) => {
+ expect(row.scrollWidth).toBeLessThanOrEqual(row.clientWidth)
+ })
+ })
+
+ // and the name is inside the viewport, not off to the left of it
+ const name = canvas.getByText('Microsoft Graph')
+ const phone = canvasElement.querySelector('[data-testid="phone"]')
+ expect(name.getBoundingClientRect().left).toBeGreaterThanOrEqual(
+ phone.getBoundingClientRect().left
+ )
+ },
+}
diff --git a/tests/components/CippComponents/CippAutocomplete.test.jsx b/tests/components/CippComponents/CippAutocomplete.test.jsx
index 1aabdf1c5039..90dbd2aa6a36 100644
--- a/tests/components/CippComponents/CippAutocomplete.test.jsx
+++ b/tests/components/CippComponents/CippAutocomplete.test.jsx
@@ -305,4 +305,91 @@ describe('CippAutoComplete', () => {
expect(options[0]).toHaveTextContent('Alpha')
})
})
+
+ // Multi-select clears the native input after chips are selected; HTML5 required must
+ // track selection state or submit falsely fails with "Please fill out this field".
+ describe('required HTML5 vs selection', () => {
+ it('marks the input required when empty, and keeps the label required', () => {
+ renderWithProviders(
+ {}}
+ />
+ )
+ const input = screen.getByRole('combobox')
+ expect(input).toBeRequired()
+ expect(document.querySelector('.Mui-required')).toBeTruthy()
+ expect(document.querySelector('.MuiFormLabel-asterisk')).toBeTruthy()
+ })
+
+ it('clears HTML5 required on the input after a multi selection, label stays required', async () => {
+ const user = userEvent.setup()
+ renderWithProviders(
+ {}}
+ />
+ )
+ await user.click(screen.getByRole('combobox'))
+ await user.click(await screen.findByRole('option', { name: 'Alpha' }))
+ expect(screen.getByRole('combobox')).not.toBeRequired()
+ expect(document.querySelector('.Mui-required')).toBeTruthy()
+ expect(document.querySelector('.MuiFormLabel-asterisk')).toBeTruthy()
+ })
+ })
+
+ // TextField forwards what it doesn't consume to the FormControl root, so a leak lands as a DOM attr
+ describe('prop routing', () => {
+ it('keeps autocomplete-only props off the DOM', () => {
+ const { container } = renderWithProviders(
+ {}}
+ noOptionsText="nothing here"
+ />
+ )
+ expect(container.querySelector('[nooptionstext]')).toBeNull()
+ })
+
+ it('routes variant to the text field, not to the autocomplete root', () => {
+ const { container } = renderWithProviders(
+ {}}
+ variant="outlined"
+ />
+ )
+ // outlined draws the notched fieldset/legend, the themed filled default does not
+ expect(container.querySelector('fieldset legend')).toBeTruthy()
+ expect(container.querySelector('[variant]')).toBeNull()
+ })
+
+ it('forwards filterSelectedOptions to the autocomplete, selected option stays listed', async () => {
+ const user = userEvent.setup()
+ renderWithProviders(
+ {}}
+ filterSelectedOptions={false}
+ />
+ )
+ await user.click(screen.getByRole('combobox'))
+ expect(await screen.findByRole('option', { name: 'Alpha' })).toBeInTheDocument()
+ })
+ })
})
diff --git a/tests/components/CippComponents/CippAutopilotProfileDrawer.test.jsx b/tests/components/CippComponents/CippAutopilotProfileDrawer.test.jsx
new file mode 100644
index 000000000000..6dbb03a2797b
--- /dev/null
+++ b/tests/components/CippComponents/CippAutopilotProfileDrawer.test.jsx
@@ -0,0 +1,163 @@
+import React from 'react'
+import { act, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { renderWithProviders } from '../../test-utils'
+import { api, apiCallMock, getResult } from '../../mocks/api-call'
+import { CippAutopilotProfileDrawer } from '../../../src/components/CippComponents/CippAutopilotProfileDrawer'
+
+// The tenant selector talks to Graph; the autopilot drawer only needs it to drive
+// `selectedTenants` on the form so the single-tenant gate around the group picker works.
+const tenants = vi.hoisted(() => ({ value: [] }))
+const tenantForm = vi.hoisted(() => ({ current: null }))
+vi.mock(
+ '../../../src/components/CippComponents/CippFormTenantSelector',
+ async () => {
+ const React = await import('react')
+ return {
+ CippFormTenantSelector: ({ formControl, name = 'selectedTenants' }) => {
+ tenantForm.current = formControl
+ React.useEffect(() => {
+ formControl.setValue(name, tenants.value, {
+ shouldValidate: true,
+ shouldDirty: true,
+ })
+ }, [formControl, name])
+ return null
+ },
+ }
+ }
+)
+
+vi.mock('../../../src/api/ApiCall', () => apiCallMock())
+
+const singleTenant = [{ value: 'contoso.com', label: 'Contoso' }]
+const multiTenant = [
+ { value: 'contoso.com', label: 'Contoso' },
+ { value: 'fabrikam.com', label: 'Fabrikam' },
+]
+const groupsResult = getResult({ data: [] })
+const authWithGroupRead = getResult({
+ data: {
+ clientPrincipal: { userRoles: ['custom'] },
+ permissions: ['Endpoint.Autopilot.ReadWrite', 'Identity.Group.Read'],
+ },
+})
+const authWithoutGroupRead = getResult({
+ data: {
+ clientPrincipal: { userRoles: ['custom'] },
+ permissions: ['Endpoint.Autopilot.ReadWrite'],
+ },
+})
+
+async function openDrawer() {
+ const user = userEvent.setup()
+ renderWithProviders( )
+ await user.click(screen.getByRole('button', { name: 'Add Profile' }))
+ return user
+}
+
+describe('CippAutopilotProfileDrawer', () => {
+ beforeEach(() => {
+ tenants.value = singleTenant
+ tenantForm.current = null
+ api.get = (options) =>
+ options.url === '/api/me' ? authWithGroupRead : groupsResult
+ api.post = { ...api.post, mutate: vi.fn() }
+ })
+
+ it('shows no group UI while "Assign to all devices" is on (default)', async () => {
+ await openDrawer()
+ expect(
+ screen.queryByText('Assign to Selected Groups')
+ ).not.toBeInTheDocument()
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+ })
+
+ it('warns instead of group-picking when more than one tenant is selected', async () => {
+ tenants.value = multiTenant
+ const user = await openDrawer()
+ await user.click(screen.getByLabelText('Assign to all devices'))
+ expect(
+ screen.getByText(/profiling by group requires selecting a single tenant/i)
+ ).toBeInTheDocument()
+ expect(
+ screen.queryByLabelText('Assign to Selected Groups')
+ ).not.toBeInTheDocument()
+ })
+
+ it('shows the group picker when groups are off and exactly one tenant is selected', async () => {
+ const user = await openDrawer()
+ await user.click(screen.getByLabelText('Assign to all devices'))
+ expect(
+ screen.queryByText(/profiling by group requires/i)
+ ).not.toBeInTheDocument()
+ expect(
+ screen.getByRole('combobox', { name: 'Assign to Selected Groups' })
+ ).toBeInTheDocument()
+ })
+
+ it('does not load the group picker without Identity Group Read permission', async () => {
+ api.get = (options) =>
+ options.url === '/api/me' ? authWithoutGroupRead : groupsResult
+
+ const user = await openDrawer()
+ await user.click(screen.getByLabelText('Assign to all devices'))
+
+ expect(
+ screen.getByText(/requires the Identity Group Read permission/i)
+ ).toBeInTheDocument()
+ expect(
+ screen.queryByLabelText('Assign to Selected Groups')
+ ).not.toBeInTheDocument()
+ })
+
+ it('drops selected groups when the tenant changes before submission', async () => {
+ const user = await openDrawer()
+ await user.click(screen.getByLabelText('Assign to all devices'))
+
+ act(() => {
+ tenantForm.current.setValue('GroupIds', [
+ { value: 'group-1', label: 'Group 1' },
+ ])
+ tenantForm.current.setValue('selectedTenants', [
+ { value: 'fabrikam.com', label: 'Fabrikam' },
+ ])
+ })
+
+ await user.type(
+ screen.getByRole('textbox', { name: 'Display Name' }),
+ 'Test AP'
+ )
+ const submit = screen.getByRole('button', { name: 'Create Profile' })
+ await waitFor(() => expect(submit).toBeEnabled())
+ await user.click(submit)
+
+ await waitFor(() => expect(api.post.mutate).toHaveBeenCalledTimes(1))
+ expect(api.post.mutate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({ GroupIds: [] }),
+ })
+ )
+ })
+
+ it('submits to AddAutopilotConfig with group ids as an array', async () => {
+ const user = await openDrawer()
+ await user.type(
+ screen.getByRole('textbox', { name: 'Display Name' }),
+ 'Test AP'
+ )
+ const submit = screen.getByRole('button', { name: 'Create Profile' })
+ await waitFor(() => expect(submit).toBeEnabled())
+ await user.click(submit)
+
+ await waitFor(() => {
+ expect(api.post.mutate).toHaveBeenCalledTimes(1)
+ })
+ expect(api.post.mutate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ url: '/api/AddAutopilotConfig',
+ data: expect.objectContaining({ DisplayName: 'Test AP', GroupIds: [] }),
+ })
+ )
+ })
+})
diff --git a/tests/components/CippComponents/CippBottomSheet.stories.jsx b/tests/components/CippComponents/CippBottomSheet.stories.jsx
new file mode 100644
index 000000000000..71e9268c2b0c
--- /dev/null
+++ b/tests/components/CippComponents/CippBottomSheet.stories.jsx
@@ -0,0 +1,207 @@
+import React from 'react'
+import { within, expect, userEvent, waitFor } from 'storybook/test'
+import {
+ Button,
+ Dialog,
+ DialogContent,
+ List,
+ ListItemButton,
+ ListItemText,
+ Typography,
+} from '@mui/material'
+import { CippBottomSheet } from '../../../src/components/CippComponents/CippBottomSheet'
+import { shrinkToPhoneViewport } from '../../viewport'
+
+// The mobile stand-in for a desktop Menu: every place the app opens a Menu on a pointer
+// device opens one of these below md instead.
+const SheetHarness = ({ children, triggerLabel = 'Open sheet', ...sheetProps }) => {
+ const [open, setOpen] = React.useState(false)
+ return (
+ <>
+ setOpen(true)}>
+ {triggerLabel}
+
+ setOpen(false)} {...sheetProps}>
+ {children}
+
+ >
+ )
+}
+
+const actionRows = ['Edit user', 'Reset password', 'Block sign-in'].map((label) => (
+
+
+
+))
+
+export default {
+ title: 'Components/CippComponents/CippBottomSheet',
+ component: CippBottomSheet,
+ tags: ['autodocs'],
+}
+
+export const WithTitle = {
+ render: () => (
+
+ {actionRows}
+
+ ),
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement)
+ const body = within(document.body)
+
+ await step('opens on tap and shows its rows', async () => {
+ await userEvent.click(canvas.getByRole('button', { name: 'Open sheet' }))
+ await waitFor(() => expect(body.getByText('Row actions')).toBeInTheDocument())
+ expect(body.getByText('Reset password')).toBeInTheDocument()
+ })
+
+ await step('closes on backdrop tap', async () => {
+ await userEvent.click(document.querySelector('.MuiBackdrop-root'))
+ await waitFor(() => expect(body.queryByText('Row actions')).not.toBeInTheDocument())
+ })
+ },
+}
+
+export const WithFooter = {
+ render: () => (
+
+ Apply to 12 selected
+
+ }
+ >
+ {actionRows}
+
+ ),
+}
+
+export const LongContentScrolls = {
+ render: () => (
+
+
+ {Array.from({ length: 30 }, (_, i) => (
+
+
+
+ ))}
+
+
+ ),
+}
+
+// Regression guard for the live bug: popout table dialogs sit at zIndex.modal (1300), so a
+// plain Drawer (1200) opened from inside one is invisible. The sheet claims modal + 1.
+export const OverADialog = {
+ render: () => {
+ const [dialogOpen, setDialogOpen] = React.useState(true)
+ return (
+ <>
+ setDialogOpen(true)}>
+ Reopen dialog
+
+ setDialogOpen(false)} fullWidth>
+
+
+ A popout table lives here. Its filter sheet must layer above this dialog.
+
+
+ {actionRows}
+
+
+
+ >
+ )
+ },
+ play: async ({ step }) => {
+ const body = within(document.body)
+
+ await step('sheet renders above the dialog', async () => {
+ await userEvent.click(body.getByRole('button', { name: 'Open filters' }))
+ const sheetRoot = await waitFor(() => {
+ const title = body.getByText('Filters')
+ return title.closest('.MuiDrawer-root')
+ })
+ const dialogRoot = document.querySelector('.MuiDialog-root')
+ const sheetZ = Number(window.getComputedStyle(sheetRoot).zIndex)
+ const dialogZ = Number(window.getComputedStyle(dialogRoot).zIndex)
+ expect(sheetZ).toBeGreaterThan(dialogZ)
+ })
+ },
+}
+
+// The grab handle used to be decoration — a 36x4 bar that promised a gesture nothing
+// implemented. Only a real browser can settle whether the drag works: jsdom has no layout,
+// so the paper's height is 0 and the swipe distance the gesture is measured against is
+// meaningless there.
+export const DragHandleDismisses = {
+ render: () => (
+
+ {actionRows}
+
+ ),
+ play: async ({ canvasElement }) => {
+ const onAPhone = await shrinkToPhoneViewport()
+ const canvas = within(canvasElement)
+ const body = within(document.body)
+
+ await userEvent.click(canvas.getByRole('button', { name: 'Open sheet' }))
+ await body.findByText('Reset password')
+ if (!onAPhone) return
+
+ const paper = document.querySelector('.MuiDrawer-paper')
+ const handle = paper.firstElementChild
+ const start = handle.getBoundingClientRect()
+
+ // A real touch drag down the screen, starting on the handle.
+ const at = (clientY) =>
+ new Touch({
+ identifier: 1,
+ target: handle,
+ clientX: start.x + start.width / 2,
+ clientY,
+ })
+ // Dispatched ON the handle and left to bubble: MUI reads event.target to decide the
+ // gesture started inside the paper, so firing at the document would bail immediately.
+ const fire = (type, clientY) =>
+ handle.dispatchEvent(
+ new TouchEvent(type, {
+ bubbles: true,
+ cancelable: true,
+ touches: type === 'touchend' ? [] : [at(clientY)],
+ changedTouches: [at(clientY)],
+ })
+ )
+
+ // MUI flags "maybe swiping" in React state on touchstart and ignores moves until that
+ // has been applied, so the gesture has to be spread across ticks like a real one.
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 30))
+ const from = start.y + start.height / 2
+ fire('touchstart', from)
+ await tick()
+ for (const dy of [20, 60, 120, 200, 260]) {
+ fire('touchmove', from + dy)
+ await tick()
+ }
+ const draggedTo = new DOMMatrixReadOnly(getComputedStyle(paper).transform).m42
+ expect(draggedTo).toBeGreaterThan(100)
+ fire('touchend', from + 260)
+
+ // The exit has to continue from where the finger let go. Slide probes the paper's
+ // untranslated position when the exit starts (Slide.js getTranslateValue), and the browser
+ // takes that probe as the transition's start, which puts the sheet back at full height for
+ // the length of the close.
+ const firstExitFrame = await new Promise((resolve) => {
+ requestAnimationFrame(() =>
+ requestAnimationFrame(() =>
+ resolve(new DOMMatrixReadOnly(getComputedStyle(paper).transform).m42)
+ )
+ )
+ })
+ expect(firstExitFrame).toBeGreaterThan(draggedTo * 0.6)
+
+ await waitFor(() => expect(body.queryByText('Reset password')).not.toBeInTheDocument())
+ },
+}
diff --git a/tests/components/CippComponents/CippBreadcrumbNav.test.jsx b/tests/components/CippComponents/CippBreadcrumbNav.test.jsx
new file mode 100644
index 000000000000..79794a7045a0
--- /dev/null
+++ b/tests/components/CippComponents/CippBreadcrumbNav.test.jsx
@@ -0,0 +1,76 @@
+import { screen } from '@testing-library/react'
+import { renderWithProviders } from '../../test-utils'
+import { CippBreadcrumbNav } from '../../../src/components/CippComponents/CippBreadcrumbNav'
+
+// second require.context consumer, this one globs every pages/**/tabOptions.json. covers the
+// subdirectory + regex arms of the polyfill that the tutorial glob (flat, no subdirs) doesn't.
+// 'Groups' only reaches the trail through src/pages/tenant/administration/tenants/tabOptions.json
+const routerState = vi.hoisted(() => ({ pathname: '/tenant/administration/tenants/groups' }))
+const layoutState = vi.hoisted(() => ({ isMobile: false }))
+vi.mock('../../../src/hooks/use-breakpoint', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useIsMobileLayout: () => layoutState.isMobile,
+}))
+vi.mock('next/router', () => ({
+ useRouter: () => ({
+ pathname: routerState.pathname,
+ asPath: routerState.pathname,
+ query: {},
+ isReady: true,
+ push: () => Promise.resolve(),
+ replace: () => Promise.resolve(),
+ events: { on: () => {}, off: () => {}, emit: () => {} },
+ }),
+}))
+
+describe('CippBreadcrumbNav', () => {
+ beforeEach(() => {
+ routerState.pathname = '/tenant/administration/tenants/groups'
+ layoutState.isMobile = false
+ })
+
+ // The dashboard's rail is one crumb saying "Overview" directly above a picker saying
+ // "Overview" — a single crumb is no hierarchy, so on phones the rail stands down.
+ it('hides the rail on mobile when there is no hierarchy to show', () => {
+ routerState.pathname = '/'
+ layoutState.isMobile = true
+ renderWithProviders( )
+
+ expect(screen.queryByLabelText('page hierarchy')).not.toBeInTheDocument()
+ })
+
+ // "Overview > Identity" is the dashboard's own tab set — the exact list the view picker
+ // beneath it presents, so on phones it says nothing the page doesn't.
+ it('hides the rail on mobile across all dashboard views, not just the root', () => {
+ routerState.pathname = '/dashboardv2/identity'
+ layoutState.isMobile = true
+ renderWithProviders( )
+
+ expect(screen.queryByLabelText('page hierarchy')).not.toBeInTheDocument()
+ })
+
+ it('keeps the dashboard rail on desktop', () => {
+ routerState.pathname = '/dashboardv2/identity'
+ renderWithProviders( )
+ expect(screen.getByLabelText('page hierarchy')).toBeInTheDocument()
+ })
+
+ it('keeps a single-crumb rail on desktop, and deep rails on mobile', () => {
+ routerState.pathname = '/'
+ renderWithProviders( )
+ expect(screen.getByLabelText('page hierarchy')).toBeInTheDocument()
+ })
+
+ it('keeps a multi-crumb rail on mobile', () => {
+ layoutState.isMobile = true
+ renderWithProviders( )
+ expect(screen.getByText('Groups')).toBeInTheDocument()
+ })
+
+ it('labels the tab crumb from the tabOptions require.context', () => {
+ renderWithProviders( )
+
+ expect(screen.getByLabelText('page hierarchy')).toBeInTheDocument()
+ expect(screen.getByText('Groups')).toBeInTheDocument()
+ })
+})
diff --git a/tests/components/CippComponents/CippExpandableAlert.stories.jsx b/tests/components/CippComponents/CippExpandableAlert.stories.jsx
new file mode 100644
index 000000000000..19ebf1403927
--- /dev/null
+++ b/tests/components/CippComponents/CippExpandableAlert.stories.jsx
@@ -0,0 +1,80 @@
+import React from 'react'
+import { within, waitFor, expect } from 'storybook/test'
+import { userEvent } from 'storybook/test'
+import { CippExpandableAlert } from '../../../src/components/CippComponents/CippExpandableAlert'
+import { shrinkToPhoneViewport, growToDesktopViewport } from '../../viewport'
+
+export default {
+ title: 'Components/CippComponents/CippExpandableAlert',
+ component: CippExpandableAlert,
+ tags: ['autodocs'],
+}
+
+const LONG_TEXT =
+ "Custom roles can be used to restrict permissions for users with the 'editor' or " +
+ "'readonly' roles in CIPP. They can be limited to a subset of tenants and API permissions. " +
+ 'Built-in and custom roles can be assigned to Entra security groups for granular access ' +
+ 'control. This sentence pads the message past any phone clamp so the toggle must appear.'
+
+const SHORT_TEXT = 'Nothing here needs a second look.'
+
+// A page-intro alert used to fill most of the first phone screen; the clamp keeps it to a
+// few lines and hands the rest to a toggle.
+export const ClampsLongMessagesOnAPhone = {
+ render: () => {LONG_TEXT} ,
+ play: async ({ canvasElement, step }) => {
+ const onAPhone = await shrinkToPhoneViewport()
+ if (!onAPhone) return
+ const canvas = within(canvasElement)
+
+ await step('the message is clipped and offers Show more', async () => {
+ const toggle = await canvas.findByRole('button', { name: /show more/i })
+ const message = canvas.getByText(/Custom roles/, { exact: false })
+ expect(message.scrollHeight).toBeGreaterThan(message.clientHeight)
+ expect(toggle).toBeInTheDocument()
+ })
+
+ await step('expanding shows everything and offers Show less', async () => {
+ await userEvent.click(canvas.getByRole('button', { name: /show more/i }))
+ const message = canvas.getByText(/Custom roles/, { exact: false })
+ await waitFor(() => {
+ expect(message.scrollHeight).toBeLessThanOrEqual(message.clientHeight + 1)
+ expect(canvas.getByRole('button', { name: /show less/i })).toBeInTheDocument()
+ })
+ })
+ },
+}
+
+// Measured, not assumed: a message that fits its clamp renders as a plain alert.
+export const LeavesShortMessagesAlone = {
+ render: () => {SHORT_TEXT} ,
+ play: async ({ canvasElement, step }) => {
+ const onAPhone = await shrinkToPhoneViewport()
+ if (!onAPhone) return
+ const canvas = within(canvasElement)
+
+ await step('no toggle for a message that already fits', async () => {
+ await canvas.findByText(SHORT_TEXT)
+ await waitFor(() => {
+ expect(canvas.queryByRole('button', { name: /show more/i })).not.toBeInTheDocument()
+ })
+ })
+ },
+}
+
+export const NeverClampsOnDesktop = {
+ render: () => {LONG_TEXT} ,
+ play: async ({ canvasElement, step }) => {
+ const onDesktop = await growToDesktopViewport()
+ if (!onDesktop) return
+ const canvas = within(canvasElement)
+
+ await step('full message, no toggle', async () => {
+ const message = await canvas.findByText(/Custom roles/, { exact: false })
+ await waitFor(() => {
+ expect(message.scrollHeight).toBeLessThanOrEqual(message.clientHeight + 1)
+ expect(canvas.queryByRole('button', { name: /show more/i })).not.toBeInTheDocument()
+ })
+ })
+ },
+}
diff --git a/tests/components/CippComponents/CippMobileTenantPicker.stories.jsx b/tests/components/CippComponents/CippMobileTenantPicker.stories.jsx
new file mode 100644
index 000000000000..4d68cac88e65
--- /dev/null
+++ b/tests/components/CippComponents/CippMobileTenantPicker.stories.jsx
@@ -0,0 +1,117 @@
+import React from 'react'
+import { http, HttpResponse } from 'msw'
+import { within, expect, userEvent, waitFor } from 'storybook/test'
+import { Box, Paper, Stack } from '@mui/material'
+import { CippMobileTenantPicker } from '../../../src/components/CippComponents/CippMobileTenantPicker'
+
+const tenants = [
+ { customerId: 'all', displayName: 'All Tenants', defaultDomainName: 'AllTenants' },
+ { customerId: 't-1', displayName: 'Contoso Ltd', defaultDomainName: 'contoso.com' },
+ { customerId: 't-2', displayName: 'Fabrikam Inc', defaultDomainName: 'fabrikam.com' },
+ { customerId: 't-3', displayName: 'Northwind Traders', defaultDomainName: 'northwind.com' },
+ { customerId: 't-4', displayName: 'Adventure Works', defaultDomainName: 'adventure-works.com' },
+]
+
+export default {
+ title: 'Components/CippComponents/CippMobileTenantPicker',
+ component: CippMobileTenantPicker,
+ tags: ['autodocs'],
+ parameters: {
+ msw: {
+ handlers: [http.get('*/api/listTenants', () => HttpResponse.json(tenants))],
+ },
+ },
+ decorators: [
+ (Story) => (
+ // Stands in for the mobile top bar, where the chip takes the width a search icon
+ // used to occupy (universal search moved into the account menu).
+
+
+
+
+
+
+
+ ),
+ ],
+}
+
+export const Chip = {
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement)
+
+ await step('chip shows the current tenant name', async () => {
+ await waitFor(() => expect(canvasElement.textContent).toContain('testdomain.com'))
+ })
+ },
+}
+
+export const PickerOpen = {
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement)
+ const body = within(document.body)
+
+ await step('the chip opens a fullscreen picker listing every tenant', async () => {
+ await userEvent.click(canvas.getByRole('button'))
+ await waitFor(() => expect(body.getByText('Contoso Ltd')).toBeInTheDocument())
+ expect(body.getByText('Fabrikam Inc')).toBeInTheDocument()
+ expect(body.getByText('All Tenants')).toBeInTheDocument()
+ })
+
+ // Avatar's default colour is background.default, so setting only bgcolor leaves the
+ // globe a dark grey sitting on the accent. Real browser: read what actually painted.
+ await step('the All Tenants glyph contrasts with the accent behind it', async () => {
+ const avatar = body
+ .getByText('All Tenants')
+ .closest('[role="button"]')
+ .querySelector('.MuiAvatar-root')
+ const style = getComputedStyle(avatar)
+ expect(style.backgroundColor).not.toBe(style.color)
+
+ const luminance = (rgb) => {
+ const [r, g, b] = rgb.match(/\d+/g).map(Number)
+ const channel = (c) => {
+ const v = c / 255
+ return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
+ }
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
+ }
+ const a = luminance(style.color)
+ const b = luminance(style.backgroundColor)
+ const contrast = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
+ expect(contrast).toBeGreaterThan(3)
+ })
+ },
+}
+
+export const SearchFiltersTheList = {
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement)
+ const body = within(document.body)
+
+ await userEvent.click(canvas.getByRole('button'))
+ await waitFor(() => expect(body.getByText('Contoso Ltd')).toBeInTheDocument())
+
+ await step('search narrows by display name', async () => {
+ await userEvent.type(body.getByPlaceholderText(/search/i), 'north')
+ await waitFor(() => expect(body.queryByText('Contoso Ltd')).toBeNull())
+ expect(body.getByText('Northwind Traders')).toBeInTheDocument()
+ })
+ },
+}
+
+export const FavoritingATenant = {
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement)
+ const body = within(document.body)
+
+ await userEvent.click(canvas.getByRole('button'))
+ await waitFor(() => expect(body.getByText('Fabrikam Inc')).toBeInTheDocument())
+
+ await step('favoriting promotes the tenant into a Favorites section', async () => {
+ const favoriteButtons = body.getAllByRole('button', { name: /favorite/i })
+ await userEvent.click(favoriteButtons[1])
+ await waitFor(() => expect(body.getByText('Favorites')).toBeInTheDocument())
+ })
+ },
+}
diff --git a/tests/components/CippComponents/CippOffCanvas.test.jsx b/tests/components/CippComponents/CippOffCanvas.test.jsx
index 9bb9a8d39769..dbe2f48737c4 100644
--- a/tests/components/CippComponents/CippOffCanvas.test.jsx
+++ b/tests/components/CippComponents/CippOffCanvas.test.jsx
@@ -1,9 +1,46 @@
import React, { useState } from 'react'
-import { screen, within } from '@testing-library/react'
+import { act, cleanup, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Button } from '@mui/material'
import { renderWithTheme } from '../../test-utils'
import { CippOffCanvas } from '../../../src/components/CippComponents/CippOffCanvas'
+import { resetOverlayHistory } from '../../../src/utils/overlay-history'
+
+// jsdom has no width-based matchMedia, so the mobile branch has to be stubbed in. Every
+// query the drawer asks about below md is a max-width one.
+const useMobileViewport = () => {
+ const cache = new Map()
+ window.matchMedia = (query) => {
+ if (!cache.has(query)) {
+ cache.set(query, {
+ matches: query.includes('max-width'),
+ media: query,
+ onchange: null,
+ addListener: () => {},
+ removeListener: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ dispatchEvent: () => false,
+ })
+ }
+ return cache.get(query)
+ }
+}
+
+const swipeBack = async () => {
+ await act(async () => {
+ const settled = new Promise((resolve) =>
+ window.addEventListener('popstate', resolve, { once: true })
+ )
+ window.history.back()
+ await settled
+ })
+}
+
+afterEach(() => {
+ resetOverlayHistory()
+ delete window.matchMedia
+})
const mockDeviceData = {
displayName: 'DESKTOP-ENTRA-01',
@@ -15,7 +52,12 @@ const mockDeviceData = {
},
}
-const InteractiveWrapper = ({ onClose, onNavigateUp, onNavigateDown, ...props }) => {
+const InteractiveWrapper = ({
+ onClose,
+ onNavigateUp,
+ onNavigateDown,
+ ...props
+}) => {
const [open, setOpen] = useState(false)
return (
<>
@@ -88,6 +130,55 @@ describe('CippOffCanvas', () => {
expect(onClose).toHaveBeenCalledTimes(1)
})
+ it('closes on the phone back gesture instead of navigating the list page away', async () => {
+ useMobileViewport()
+ const user = userEvent.setup()
+ const onClose = vi.fn()
+
+ renderWithTheme(
+
+ )
+
+ await user.click(screen.getByRole('button', { name: /open offcanvas/i }))
+ expect(within(document.body).getByText('Device Details')).toBeVisible()
+
+ await swipeBack()
+
+ expect(onClose).toHaveBeenCalledTimes(1)
+ await waitFor(() =>
+ expect(
+ within(document.body).queryByText('Device Details')
+ ).not.toBeInTheDocument()
+ )
+ })
+
+ it('leaves the back button to the router on desktop', async () => {
+ const user = userEvent.setup()
+ const onClose = vi.fn()
+
+ renderWithTheme(
+
+ )
+
+ await user.click(screen.getByRole('button', { name: /open offcanvas/i }))
+ // Somewhere to go back to, so the press is a real navigation attempt.
+ window.history.pushState({}, '')
+ await swipeBack()
+
+ expect(onClose).not.toHaveBeenCalled()
+ expect(within(document.body).getByText('Device Details')).toBeVisible()
+ })
+
it('maps extendedInfoFields to values, dotted paths resolve and missing fields fall back to N/A', () => {
renderWithTheme(
{
// field absent from extendedData renders the N/A fallback
expect(root.getByText('N/A')).toBeInTheDocument()
})
+
+ it('renders the info card above children by default and below with actionsPosition bottom', () => {
+ const renderCanvas = (actionsPosition) => {
+ renderWithTheme(
+ (
+ child content
+ )}
+ />
+ )
+ }
+ const childrenBox = () =>
+ within(document.body).getByTestId('custom-children')
+ const infoValue = () => within(document.body).getByText('DESKTOP-ENTRA-01')
+
+ renderCanvas('top')
+ expect(
+ childrenBox().compareDocumentPosition(infoValue()) &
+ Node.DOCUMENT_POSITION_PRECEDING
+ ).toBeTruthy()
+
+ cleanup()
+ renderCanvas('bottom')
+ expect(
+ childrenBox().compareDocumentPosition(infoValue()) &
+ Node.DOCUMENT_POSITION_FOLLOWING
+ ).toBeTruthy()
+ })
})
diff --git a/tests/components/CippComponents/CippPageActionsFab.stories.jsx b/tests/components/CippComponents/CippPageActionsFab.stories.jsx
new file mode 100644
index 000000000000..5609ef91e917
--- /dev/null
+++ b/tests/components/CippComponents/CippPageActionsFab.stories.jsx
@@ -0,0 +1,215 @@
+import React from 'react'
+import { within, expect, userEvent, waitFor, fn } from 'storybook/test'
+import {
+ Box,
+ Button,
+ Divider,
+ List,
+ ListItemButton,
+ ListItemIcon,
+ ListItemText,
+ ListSubheader,
+ MenuItem,
+ Typography,
+} from '@mui/material'
+import { Add, Assessment, Public, Summarize } from '@mui/icons-material'
+import { CippPageActionsFab } from '../../../src/components/CippComponents/CippPageActionsFab'
+import { TabNavigationContext } from '../../../src/layouts/tab-navigation-context'
+
+const TABS = [
+ { label: 'Edit Tenant', path: '/tenant/manage/edit', icon: 'Settings' },
+ { label: 'Manage Drift', path: '/tenant/manage/drift', icon: 'Sync' },
+ { label: 'Configuration Backup', path: '/tenant/manage/backup', icon: 'Backup' },
+]
+
+const LAYOUT_ACTIONS = [{ label: 'Reset Password', onClick: () => {} }]
+
+// Stands in for a headered tabbed layout: below md its header Actions menu is clipped, so
+// those actions ride in whichever FAB owns the corner. Its tabs do not — those live in the
+// title row (CippTabPicker), which is why this sheet never shows a "Views" section.
+const withLayoutActions = (Story) => (
+ {},
+ actions: LAYOUT_ACTIONS,
+ claim: () => {},
+ release: () => {},
+ isActionCornerClaimed: false,
+ }}
+ >
+
+
+)
+
+export default {
+ title: 'Components/CippComponents/CippPageActionsFab',
+ component: CippPageActionsFab,
+ tags: ['autodocs'],
+ decorators: [
+ (Story) => (
+
+
+ Page content. The FAB is fixed to the viewport's bottom-right corner — below md
+ that corner belongs to page actions (CippSpeedDial hides itself there).
+
+
+
+ ),
+ ],
+}
+
+// How table pages use it: cardButton is an arbitrary Box of drawer triggers laid out for a
+// desktop CardHeader, restacked vertically by the primitive's descendant CSS.
+export const RestackedCardButton = {
+ render: () => (
+
+
+ }>
+ Add User
+
+ Bulk Add
+ Invite Guest
+
+
+ ),
+ play: async ({ step }) => {
+ const body = within(document.body)
+
+ await step('opens the sheet from the FAB', async () => {
+ await userEvent.click(body.getByRole('button', { name: 'Page actions' }))
+ await waitFor(() => expect(body.getByText('Actions')).toBeInTheDocument())
+ })
+
+ await step('children are restacked to full width', async () => {
+ const addButton = body.getByRole('button', { name: 'Add User' })
+ expect(window.getComputedStyle(addButton).justifyContent).toBe('flex-start')
+ })
+
+ await step('tapping an action closes the sheet', async () => {
+ await userEvent.click(body.getByRole('button', { name: 'Bulk Add' }))
+ // keepMounted: a cardButton child owns its own drawer, so the sheet hides rather
+ // than unmounting — otherwise that drawer would vanish the moment it opened.
+ await waitFor(() => expect(body.getByText('Actions')).not.toBeVisible())
+ })
+ },
+}
+
+// How the dashboard uses it: purpose-built list rows, so restacking is off.
+export const DashboardSections = {
+ render: (args) => (
+
+
+ Portals
+
+ }
+ >
+ {['M365', 'Exchange', 'Entra'].map((label) => (
+
+
+
+
+
+
+ ))}
+
+
+
+ Reports
+
+ }
+ >
+ {/* ExecutiveReportButton renders exactly this: a MenuItem, not a Button */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+ args: {
+ onExecutiveSummary: fn(),
+ },
+ play: async ({ args, step }) => {
+ const body = within(document.body)
+
+ await step('sections render under their subheaders', async () => {
+ await userEvent.click(body.getByRole('button', { name: 'Page actions' }))
+ await waitFor(() => expect(body.getByText('Dashboard actions')).toBeInTheDocument())
+ expect(body.getByText('Portals')).toBeInTheDocument()
+ expect(body.getByText('Reports')).toBeInTheDocument()
+ })
+
+ await step('a MenuItem child fires its handler and closes the sheet', async () => {
+ await userEvent.click(body.getByRole('menuitem', { name: 'Executive Summary' }))
+ expect(args.onExecutiveSummary).toHaveBeenCalled()
+ // keepMounted leaves the sheet in the DOM (so ExecutiveReportButton's own preview
+ // Dialog survives) — closed means hidden here, not unmounted.
+ await waitFor(() => expect(body.getByText('Dashboard actions')).not.toBeVisible())
+ })
+ },
+}
+
+// Under a headered tabbed layout the sheet carries the page's own action and the layout's
+// header actions, labelled as two sections. Every page-actions FAB uses the same neutral
+// glyph — a "+" only ever told the truth on pages whose sheet creates things.
+export const PageAndLayoutActions = {
+ decorators: [withLayoutActions],
+ render: () => (
+
+ }>
+ Add Variable
+
+
+ ),
+ play: async ({ step }) => {
+ const body = within(document.body)
+
+ await step('the FAB carries the one shared glyph', async () => {
+ const fab = body.getByRole('button', { name: 'Page actions' })
+ expect(within(fab).queryByTestId('AddIcon')).toBeNull()
+ expect(within(fab).getByTestId('MoreHorizIcon')).toBeInTheDocument()
+ })
+
+ await step('one sheet holds both kinds of action', async () => {
+ await userEvent.click(body.getByRole('button', { name: 'Page actions' }))
+ await waitFor(() => expect(body.getByText('Actions')).toBeInTheDocument())
+ expect(body.getByRole('button', { name: 'Add Variable' })).toBeInTheDocument()
+ expect(body.getByText('Reset Password')).toBeInTheDocument()
+ })
+
+ // Destinations moved to the title row; a FAB is for a screen's primary action.
+ await step('and no destinations', async () => {
+ expect(body.queryByText('Views')).toBeNull()
+ expect(body.queryByText('Manage Drift')).toBeNull()
+ expect(body.queryByText('Configuration Backup')).toBeNull()
+ })
+ },
+}
diff --git a/tests/components/CippComponents/CippPageActionsFab.test.jsx b/tests/components/CippComponents/CippPageActionsFab.test.jsx
new file mode 100644
index 000000000000..9019a21edfbd
--- /dev/null
+++ b/tests/components/CippComponents/CippPageActionsFab.test.jsx
@@ -0,0 +1,197 @@
+import React from "react";
+import { describe, it, expect, vi } from "vitest";
+import { screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { Button, Drawer, ListItemButton, MenuItem, Stack, Typography } from "@mui/material";
+import { CippPageActionsFab } from "../../../src/components/CippComponents/CippPageActionsFab";
+import { renderWithProviders } from "../../test-utils";
+
+const openSheet = async (user, label = "Page actions") => {
+ await user.click(screen.getByRole("button", { name: label }));
+ await screen.findByText("Sheet content");
+};
+
+describe("CippPageActionsFab", () => {
+ it("renders the FAB and opens the sheet with its children", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(
+
+ Sheet content
+ Do a thing
+
+ );
+
+ // keepMounted: the children stay mounted so a child-owned overlay survives the
+ // sheet closing, so "closed" means hidden rather than absent.
+ expect(screen.getByText("Sheet content")).not.toBeVisible();
+ await openSheet(user);
+
+ expect(screen.getByText("Sheet content")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Do a thing" })).toBeInTheDocument();
+ expect(screen.getByText("Actions")).toBeInTheDocument();
+ });
+
+ // A cardButton laid out for a desktop CardHeader is as often a Stack as a Box. Matching
+ // only Box left the row intact while every button was stretched to 100%, so three import
+ // buttons ran off the side of the sheet.
+ it("restacks a row of buttons that arrived as a Stack", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(
+
+
+ Sheet content
+ Import from CSV
+ Manual Import
+
+
+ );
+ await openSheet(user);
+
+ const row = screen.getByText("Manual Import").closest(".MuiStack-root");
+ const styles = window.getComputedStyle(row);
+ expect(styles.flexDirection).toBe("column");
+ // Stack's spacing is a margin-left that would survive the flip and indent each row
+ const button = screen.getByText("Manual Import").closest("button");
+ expect(window.getComputedStyle(button).marginLeft).toBe("0px");
+ });
+
+ // The sheet's paper is grey; a text button's default primary accent reads as
+ // orange-on-grey and doesn't match the list rows underneath it.
+ it("neutralises text buttons without flattening the branded ones", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(
+ <>
+ Untouched
+
+
+ Sheet content
+ Add User
+
+
+ >
+ );
+ await openSheet(user);
+
+ // Compared against the same button outside the sheet, so the assertion fails if the
+ // override goes away rather than merely describing MUI's defaults.
+ const inSheet = screen.getByText("Sheet content").closest("button");
+ const outside = screen.getByText("Untouched").closest("button");
+ expect(window.getComputedStyle(inSheet).color).not.toBe(
+ window.getComputedStyle(outside).color
+ );
+ // a deliberate call to action keeps its branding
+ expect(screen.getByText("Add User").closest("button").className).toMatch(/containedPrimary/);
+ });
+
+ it("uses custom title and aria-label", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(
+
+ Sheet content
+
+ );
+
+ await openSheet(user, "Dashboard shortcuts");
+ expect(screen.getByText("Dashboard actions")).toBeInTheDocument();
+ });
+
+ it("closes the sheet when a child button is tapped", async () => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+ renderWithProviders(
+
+ Sheet content
+ Do a thing
+
+ );
+
+ await openSheet(user);
+ await user.click(screen.getByRole("button", { name: "Do a thing" }));
+
+ expect(onClick).toHaveBeenCalledTimes(1);
+ await waitFor(() => expect(screen.getByText("Sheet content")).not.toBeVisible());
+ });
+
+ it("closes the sheet when a child link is tapped", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(
+
+ Sheet content
+
+ External portal
+
+
+ );
+
+ await openSheet(user);
+ await user.click(screen.getByRole("link", { name: "External portal" }));
+
+ await waitFor(() => expect(screen.getByText("Sheet content")).not.toBeVisible());
+ });
+
+ it("closes the sheet when a MenuItem child is tapped", async () => {
+ // ExecutiveReportButton renders variant="menuItem" — a , not a button
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+ renderWithProviders(
+
+ Sheet content
+ Executive Summary
+
+ );
+
+ await openSheet(user);
+ await user.click(screen.getByRole("menuitem", { name: "Executive Summary" }));
+
+ expect(onClick).toHaveBeenCalledTimes(1);
+ await waitFor(() => expect(screen.getByText("Sheet content")).not.toBeVisible());
+ });
+
+ it("keeps the sheet open when non-interactive content is tapped", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(
+
+ Sheet content
+
+ );
+
+ await openSheet(user);
+ await user.click(screen.getByText("Sheet content"));
+
+ expect(screen.getByText("Sheet content")).toBeInTheDocument();
+ });
+});
+
+// A cardButton child renders both its trigger and its own overlay (CippAddUserDrawer is a
+// button plus a CippOffCanvas). If the sheet unmounts its children on close, that overlay
+// disappears the instant it opens.
+describe("CippPageActionsFab with a child that owns an overlay", () => {
+ const DrawerAction = () => {
+ const [open, setOpen] = React.useState(false);
+ return (
+ <>
+ setOpen(true)}>Add User
+ setOpen(false)}>
+ Add user form
+
+ >
+ );
+ };
+
+ it("keeps the child's overlay open after the sheet closes", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(
+
+
+
+ );
+
+ await user.click(screen.getByRole("button", { name: "Page actions" }));
+ await user.click(await screen.findByRole("button", { name: "Add User" }));
+
+ // the tap closes the sheet and opens the child's drawer — the drawer must survive it
+ expect(await screen.findByText("Add user form")).toBeInTheDocument();
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ expect(screen.getByText("Add user form")).toBeInTheDocument();
+ });
+});
diff --git a/tests/components/CippComponents/CippQuarantineDetails.test.jsx b/tests/components/CippComponents/CippQuarantineDetails.test.jsx
new file mode 100644
index 000000000000..cab1975d1dec
--- /dev/null
+++ b/tests/components/CippComponents/CippQuarantineDetails.test.jsx
@@ -0,0 +1,180 @@
+import React from 'react'
+import { screen } from '@testing-library/react'
+import { renderWithProviders } from '../../test-utils'
+import { api, apiCallMock, getResult } from '../../mocks/api-call'
+import { CippQuarantineDetails } from '../../../src/components/CippComponents/CippQuarantineDetails'
+
+vi.mock('../../../src/api/ApiCall', async () =>
+ (await import('../../mocks/api-call')).apiCallMock()
+)
+
+import TimeAgo from 'javascript-time-ago'
+import en from 'javascript-time-ago/locale/en'
+try {
+ TimeAgo.addDefaultLocale(en)
+} catch (e) {
+ /* already added */
+}
+
+// producer shapes: row is Get-QuarantineMessage output enriched by Add-CIPPQuarantineMessageProperties,
+// analyzed is Invoke-ListMailQuarantineMessageDetails Results[0] (analyzedEmails or header fallback)
+const quarantineRow = {
+ Identity:
+ '5e5e5e5e-1111-2222-3333-444455556666\\c81d4a2e-1111-2222-3333-444455556666',
+ NetworkMessageId: '5e5e5e5e-1111-2222-3333-444455556666',
+ Tenant: 'fabrikam.com',
+ CustomerId: 'customer-1',
+ Subject: 'Suspicious invoice',
+ ReceivedTime: '2026-06-01T10:00:00Z',
+ Expires: '2026-07-01T10:00:00Z',
+ Type: 'HighConfPhish',
+ ReleaseStatus: 'NOTRELEASED',
+ PolicyType: 'AntiPhish',
+ PolicyName: 'Default AntiPhish',
+ SenderAddress: 'bad@evil.example',
+ RecipientAddress: ['user@fabrikam.com'],
+ Size: 2048,
+ Direction: 'Inbound',
+ EntityType: 'Email',
+ MessageId: '',
+ QuarantinedUser: 'user@fabrikam.com',
+ Reported: false,
+}
+
+const analyzed = {
+ recipientEmailAddress: 'user@fabrikam.com',
+ internetMessageId: '',
+ returnPath: 'bounce@evil.example',
+ directionality: 'Inbound',
+ language: 'en',
+ spamConfidenceLevel: -1,
+ bulkComplaintLevel: 1,
+ threatTypes: ['Malware'],
+ detectionMethods: ['File detonation'],
+ primaryOverrideSource: 'None',
+ policyAction: 'Quarantine',
+ senderDetail: {
+ displayName: 'Evil Sender',
+ mailFromAddress: 'bad@evil.example',
+ fromAddress: 'bad@evil.example',
+ ipv4: '203.0.113.5',
+ location: 'US',
+ },
+ originalDelivery: {
+ originalThreats: ['Malware'],
+ location: 'Quarantine',
+ action: 'Quarantined',
+ },
+ latestDelivery: {
+ latestThreats: ['Malware'],
+ location: 'Quarantine',
+ action: 'Quarantined',
+ },
+ authenticationDetails: {
+ dmarc: 'fail',
+ dkim: 'pass',
+ senderPolicyFramework: 'softfail',
+ compositeAuthentication: 'fail',
+ },
+ urls: [
+ {
+ url: 'https://evil.example/pay',
+ threatType: 'Malware',
+ detectionMethod: 'Detonated',
+ },
+ ],
+ attachments: [
+ {
+ fileName: 'invoice.pdf',
+ contentType: 'application/pdf',
+ fileSize: 1024,
+ sha256:
+ 'aa11bb22cc33dd44ee55ff6677889900aabbccddeeff00112233445566778899',
+ threatType: 'Malware',
+ malwareFamily: 'TestFamily',
+ },
+ ],
+}
+
+const detailsResult = (metadata = {}) =>
+ getResult({ data: { Results: [analyzed], Metadata: metadata } })
+
+const defaultMetadata = { Available: true, Source: 'Defender' }
+const headersResult = detailsResult({ Available: true, Source: 'Headers' })
+const defenderResult = detailsResult(defaultMetadata)
+
+describe('CippQuarantineDetails', () => {
+ it('shows the header-parsed fallback notice and targets the row tenant for enrichment', () => {
+ let detailOpts = null
+ api.get = (opts) => {
+ if (opts.url === '/api/ListMailQuarantineMessageDetails') {
+ detailOpts = opts
+ return headersResult
+ }
+ return getResult()
+ }
+ renderWithProviders( )
+
+ expect(
+ screen.getByText(/Showing details parsed from the message headers/)
+ ).toBeInTheDocument()
+ expect(detailOpts.data.tenantFilter).toBe('fabrikam.com')
+ expect(detailOpts.data.Identity).toBe(quarantineRow.Identity)
+ // fallback fields render from the analyzed-shaped object
+ expect(screen.getAllByText('Fail').length).toBeGreaterThan(0)
+ expect(screen.getByText('Softfail')).toBeInTheDocument()
+ })
+
+ it('colors phishing and malware reason chips as error', () => {
+ api.get = () => defenderResult
+ renderWithProviders( )
+ expect(
+ screen
+ .getAllByText('HighConfPhish')
+ .find((el) => el.closest('[class*="MuiChip-colorError"]'))
+ ).toBeTruthy()
+
+ api.get = () => defenderResult
+ renderWithProviders(
+
+ )
+ expect(
+ screen
+ .getAllByText('Malware')
+ .find((el) => el.closest('[class*="MuiChip-colorError"]'))
+ ).toBeTruthy()
+ })
+
+ it('colors spam and bulk reason chips as warning', () => {
+ api.get = () => defenderResult
+ renderWithProviders(
+
+ )
+ expect(
+ screen
+ .getAllByText('Spam')
+ .find((el) => el.closest('[class*="MuiChip-colorWarning"]'))
+ ).toBeTruthy()
+
+ api.get = () => defenderResult
+ renderWithProviders(
+
+ )
+ expect(
+ screen
+ .getAllByText('Bulk')
+ .find((el) => el.closest('[class*="MuiChip-colorWarning"]'))
+ ).toBeTruthy()
+ })
+
+ it('renders URL and attachment verdict tables from the analyzed enrichment', () => {
+ api.get = () => defenderResult
+ renderWithProviders( )
+
+ expect(screen.getByText('https://evil.example/pay')).toBeInTheDocument()
+ expect(screen.getByText('Detonated')).toBeInTheDocument()
+ expect(screen.getByText('invoice.pdf')).toBeInTheDocument()
+ expect(screen.getByText('TestFamily')).toBeInTheDocument()
+ expect(screen.getByText('1.0 KB')).toBeInTheDocument()
+ })
+})
diff --git a/tests/components/CippComponents/CippQuarantineTable.test.jsx b/tests/components/CippComponents/CippQuarantineTable.test.jsx
new file mode 100644
index 000000000000..84bd7efb7c20
--- /dev/null
+++ b/tests/components/CippComponents/CippQuarantineTable.test.jsx
@@ -0,0 +1,98 @@
+import React from 'react'
+import { act, screen } from '@testing-library/react'
+import { renderWithProviders, settingsWith } from '../../test-utils'
+import { api, apiCallMock, getResult } from '../../mocks/api-call'
+import { CippQuarantineTable } from '../../../src/components/CippComponents/CippQuarantineTable'
+
+const tableProps = vi.hoisted(() => ({ current: null }))
+vi.mock('../../../src/api/ApiCall', async () =>
+ (await import('../../mocks/api-call')).apiCallMock()
+)
+vi.mock('../../../src/components/CippComponents/CippTablePage.jsx', () => ({
+ CippTablePage: (props) => {
+ tableProps.current = props
+ return
+ },
+}))
+
+const quarantineRow = {
+ Identity:
+ '5e5e5e5e-1111-2222-3333-444455556666\\c81d4a2e-1111-2222-3333-444455556666',
+ NetworkMessageId: '5e5e5e5e-1111-2222-3333-444455556666',
+ Tenant: 'fabrikam.com',
+ Subject: 'Suspicious invoice',
+ MessageId: '',
+ ReceivedTime: '2026-06-01T10:00:00Z',
+ RecipientAddress: ['user@fabrikam.com'],
+ ReleaseStatus: 'NOTRELEASED',
+}
+
+describe('CippQuarantineTable', () => {
+ it('gates email-only actions to the Email tab and passes the entity type to the API', () => {
+ api.get = () => getResult()
+ const { unmount } = renderWithProviders(
+
+ )
+ const { actions, apiData } = tableProps.current
+ const labels = actions.map((action) => action.label)
+
+ expect(labels).toContain('Release')
+ expect(labels).toContain('Delete from Quarantine')
+ expect(labels).not.toContain('Preview Message')
+ expect(labels).not.toContain('Deny')
+ expect(labels).not.toContain('Block Sender')
+ expect(labels).not.toContain('Submit to Microsoft for Review')
+ expect(labels).not.toContain('Open Email Entity in Defender')
+ expect(apiData.EntityType).toBe('Teams')
+ unmount()
+
+ renderWithProviders( )
+ const emailLabels = tableProps.current.actions.map((action) => action.label)
+ expect(emailLabels).toContain('Preview Message')
+ expect(emailLabels).toContain('Deny')
+ expect(emailLabels).toContain('Submit to Microsoft for Review')
+ expect(emailLabels).toContain('Block Sender')
+ expect(emailLabels).toContain('Open Email Entity in Defender')
+ })
+
+ it('targets the row tenant for per-message calls in the AllTenants view', async () => {
+ const callOpts = []
+ api.get = (opts) => {
+ callOpts.push(opts)
+ return getResult()
+ }
+ renderWithProviders( , {
+ settings: settingsWith({ currentTenant: 'AllTenants' }),
+ })
+
+ const preview = tableProps.current.actions.find(
+ (action) => action.label === 'Preview Message'
+ )
+ await act(async () => preview.customFunction(quarantineRow))
+
+ const contentsCall = callOpts.find(
+ (opts) =>
+ opts.url === '/api/ListMailQuarantineMessage' &&
+ opts.data?.Identity === quarantineRow.Identity
+ )
+ expect(contentsCall).toBeTruthy()
+ expect(contentsCall.data.tenantFilter).toBe('fabrikam.com')
+ })
+
+ it('renders the raw message headers in the headers dialog', async () => {
+ const headerText =
+ 'Received: from mail.evil.example\r\nX-CIPP-Test: present'
+ api.get = (opts) =>
+ opts.url === '/api/ListMailQuarantineMessageHeader'
+ ? getResult({ data: { Header: headerText } })
+ : getResult()
+ renderWithProviders( )
+
+ const viewHeaders = tableProps.current.actions.find(
+ (action) => action.label === 'View Message Headers'
+ )
+ await act(async () => viewHeaders.customFunction(quarantineRow))
+
+ expect(screen.getByText(/X-CIPP-Test: present/)).toBeInTheDocument()
+ })
+})
diff --git a/tests/components/CippComponents/CippReportToolbar.stories.jsx b/tests/components/CippComponents/CippReportToolbar.stories.jsx
new file mode 100644
index 000000000000..20f37116f888
--- /dev/null
+++ b/tests/components/CippComponents/CippReportToolbar.stories.jsx
@@ -0,0 +1,99 @@
+import React from 'react'
+import { http, HttpResponse } from 'msw'
+import { within, expect, userEvent, waitFor } from 'storybook/test'
+import { Box } from '@mui/material'
+import { CippReportToolbar } from '../../../src/components/CippComponents/CippReportToolbar'
+
+const testSuites = [
+ {
+ id: 'ztna',
+ name: 'Zero Trust Network Access Tests',
+ description: "Microsoft's comprehensive security assessment",
+ type: 'builtin',
+ source: 'file',
+ },
+ {
+ id: 'custom-1',
+ name: 'My Custom Suite',
+ description: 'A tenant-specific suite',
+ type: 'custom',
+ source: 'table',
+ },
+]
+
+const handlers = [
+ http.get('*/api/ListTestReports', () => HttpResponse.json(testSuites)),
+ http.get('*/api/ListAvailableTests', () =>
+ HttpResponse.json({ IdentityTests: [], DevicesTests: [], CustomTests: [] })
+ ),
+]
+
+export default {
+ title: 'Components/CippComponents/CippReportToolbar',
+ component: CippReportToolbar,
+ tags: ['autodocs'],
+ parameters: { msw: { handlers } },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+}
+
+// The toolbar picks its layout from useIsMobileLayout (a media query), and no story in this
+// repo sets a viewport — so the mobile variant is shown by constraining the container and
+// documenting the difference rather than by faking the breakpoint.
+export const Desktop = {
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement)
+
+ await step('every suite action is an inline button', async () => {
+ await waitFor(() =>
+ expect(canvas.getByRole('button', { name: 'Refresh' })).toBeInTheDocument()
+ )
+ expect(canvas.getByRole('button', { name: 'Delete' })).toBeInTheDocument()
+ expect(canvas.getByRole('button', { name: 'Create Suite' })).toBeInTheDocument()
+ expect(canvas.getByRole('button', { name: 'Refresh test suites' })).toBeInTheDocument()
+ expect(canvas.queryByRole('button', { name: 'Test suite actions' })).toBeNull()
+ })
+ },
+}
+
+// Regression guard for the overflow this refactor fixed: the selector must be allowed to
+// shrink (minWidth: 0) so the trailing Delete button stays inside the row.
+export const NarrowDesktopKeepsButtonsInView = {
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement)
+
+ await step('the last button is not pushed past the container edge', async () => {
+ const deleteButton = await waitFor(() => canvas.getByRole('button', { name: 'Delete' }))
+ const row = deleteButton.closest('div[class*="MuiBox"]').parentElement
+ expect(deleteButton.getBoundingClientRect().right).toBeLessThanOrEqual(
+ Math.ceil(row.getBoundingClientRect().right) + 1
+ )
+ })
+ },
+}
+
+export const SuiteSelection = {
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement)
+
+ await step('the default suite is selected once the list loads', async () => {
+ // Opening the popper is left to CippAutocomplete's own stories — driving it from here
+ // crashes the browser tab in this harness.
+ await waitFor(() =>
+ expect(canvas.getByRole('combobox')).toHaveValue('Zero Trust Network Access Tests')
+ )
+ })
+ },
+}
diff --git a/tests/components/CippComponents/CippReportToolbar.test.jsx b/tests/components/CippComponents/CippReportToolbar.test.jsx
new file mode 100644
index 000000000000..3d94facfa05d
--- /dev/null
+++ b/tests/components/CippComponents/CippReportToolbar.test.jsx
@@ -0,0 +1,237 @@
+import React from "react";
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { renderWithProviders } from "../../test-utils";
+
+// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook
+const layoutState = vi.hoisted(() => ({ isMobile: false }));
+vi.mock("../../../src/hooks/use-breakpoint", () => ({
+ useIsMobileLayout: () => layoutState.isMobile,
+ useIsTabletLayout: () => false,
+ useTableViewMode: () => "table",
+}));
+
+// One registration only — ApiCall and ApiCall.jsx resolve to the same module, so a second
+// vi.mock for the extensioned path would silently replace this one.
+const apiState = vi.hoisted(() => ({ reports: [], refetch: () => {}, reportsResult: null }));
+const idlePaginated = vi.hoisted(() => ({
+ isSuccess: false,
+ isFetching: false,
+ isLoading: false,
+ isError: false,
+ data: undefined,
+ fetchNextPage: () => {},
+ refetch: () => {},
+}));
+const idlePost = vi.hoisted(() => ({
+ mutate: () => {},
+ isPending: false,
+ isSuccess: false,
+ isError: false,
+ reset: () => {},
+}));
+const idleGet = vi.hoisted(() => ({
+ isSuccess: false,
+ isFetching: false,
+ isLoading: false,
+ isError: false,
+ data: undefined,
+ refetch: () => {},
+}));
+vi.mock("../../../src/api/ApiCall", () => ({
+ // Stable result identity per test: a fresh literal each call loops the autocomplete's
+ // option-mapping effect (see tests/mocks/api-call.js).
+ ApiGetCall: ({ url }) =>
+ url === "/api/ListTestReports" ? apiState.reportsResult : idleGet,
+ ApiGetCallWithPagination: () => idlePaginated,
+ ApiPostCall: () => idlePost,
+}));
+
+const routerState = vi.hoisted(() => ({ push: vi.fn(), query: {} }));
+vi.mock("next/router", () => ({
+ useRouter: () => ({
+ isReady: true,
+ pathname: "/dashboardv2",
+ query: routerState.query,
+ push: routerState.push,
+ }),
+}));
+
+// The drawer pulls in the whole test-picker form; the toolbar contract under test is only
+// "is it open, and with which suite" — so it's stubbed down to those observable facts.
+const drawerRenders = vi.hoisted(() => ({ calls: [] }));
+vi.mock("../../../src/components/CippComponents/CippAddTestReportDrawer", () => ({
+ CippAddTestReportDrawer: (props) => {
+ drawerRenders.calls.push(props);
+ if (props.hideTrigger) {
+ return props.open ? (
+
+ {props.reportToEdit?.name ?? "no-report"}
+
+ ) : null;
+ }
+ return {props.buttonText ?? "Create Suite"} ;
+ },
+}));
+
+vi.mock("../../../src/components/CippComponents/CippApiDialog", () => ({
+ CippApiDialog: ({ createDialog, title }) =>
+ createDialog?.open ? {title}
: null,
+}));
+
+import { CippReportToolbar } from "../../../src/components/CippComponents/CippReportToolbar";
+
+const CUSTOM_SUITE = {
+ id: "custom-1",
+ name: "My Custom Suite",
+ description: "custom",
+ type: "custom",
+ source: "table",
+};
+const BUILT_IN_SUITE = {
+ id: "ztna",
+ name: "Zero Trust Network Access Tests",
+ description: "built in",
+ type: "builtin",
+ source: "file",
+};
+
+const openActionSheet = async (user) => {
+ await user.click(screen.getByRole("button", { name: "Test suite actions" }));
+ const heading = await screen.findByText("Test suite actions");
+ return within(heading.closest(".MuiDrawer-paper"));
+};
+
+describe("CippReportToolbar", () => {
+ beforeEach(() => {
+ layoutState.isMobile = false;
+ apiState.reports = [BUILT_IN_SUITE, CUSTOM_SUITE];
+ apiState.refetch = vi.fn();
+ apiState.reportsResult = {
+ isSuccess: true,
+ isFetching: false,
+ isLoading: false,
+ isError: false,
+ data: apiState.reports,
+ refetch: apiState.refetch,
+ };
+ routerState.query = {};
+ routerState.push = vi.fn();
+ drawerRenders.calls = [];
+ });
+
+ it("renders the inline desktop action buttons", () => {
+ renderWithProviders( );
+
+ expect(screen.getByRole("button", { name: "Refresh" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Create Suite" })).toBeInTheDocument();
+ // The selector's inline "Refresh test suites" icon button is desktop-only too
+ expect(screen.getByRole("button", { name: "Refresh test suites" })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Test suite actions" })).not.toBeInTheDocument();
+ });
+
+ it("collapses to a sheet trigger + kebab on mobile — no text input, no keyboard", () => {
+ layoutState.isMobile = true;
+ routerState.query = { reportId: "ztna" };
+ renderWithProviders( );
+
+ expect(screen.getByRole("button", { name: "Test suite actions" })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Delete" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Refresh" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Refresh test suites" })).not.toBeInTheDocument();
+ // the house pick-one pattern: a trigger, not an autocomplete
+ expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /switch test suite/i })).toHaveTextContent(
+ "Zero Trust Network Access Tests"
+ );
+ });
+
+ it("switches suite from the bottom sheet, routing shallowly", async () => {
+ layoutState.isMobile = true;
+ routerState.query = { reportId: "ztna" };
+ const user = userEvent.setup();
+ renderWithProviders( );
+
+ await user.click(screen.getByRole("button", { name: /switch test suite/i }));
+ const sheet = within((await screen.findByText("Test suite")).closest(".MuiDrawer-paper"));
+ // descriptions ride as secondary text, the current suite is checked
+ expect(sheet.getByText("custom")).toBeInTheDocument();
+ expect(sheet.getByText("Zero Trust Network Access Tests").closest('[role="button"]')).toHaveClass(
+ "Mui-selected"
+ );
+
+ await user.click(sheet.getByText("My Custom Suite"));
+ await waitFor(() =>
+ expect(routerState.push).toHaveBeenCalledWith(
+ expect.objectContaining({ query: expect.objectContaining({ reportId: "custom-1" }) }),
+ undefined,
+ { shallow: true }
+ )
+ );
+ });
+
+ it("offers all five suite actions in the sheet", async () => {
+ layoutState.isMobile = true;
+ const user = userEvent.setup();
+ renderWithProviders( );
+
+ const sheet = await openActionSheet(user);
+ ["Create Suite", "Run Tests", "Edit Suite", "Delete Suite", "Reload suite list"].forEach(
+ (label) => expect(sheet.getByText(label)).toBeInTheDocument()
+ );
+ });
+
+ it("disables Edit and Delete with a visible reason for a built-in suite", async () => {
+ layoutState.isMobile = true;
+ routerState.query = { reportId: "ztna" };
+ const user = userEvent.setup();
+ renderWithProviders( );
+
+ const sheet = await openActionSheet(user);
+ expect(sheet.getByText("Built-in test suites cannot be edited")).toBeInTheDocument();
+ expect(sheet.getByText("Built-in test suites cannot be deleted")).toBeInTheDocument();
+ expect(sheet.getByText("Edit Suite").closest("[role='button']")).toHaveClass("Mui-disabled");
+ });
+
+ it("opens the run-tests dialog and keeps it mounted after the sheet closes", async () => {
+ layoutState.isMobile = true;
+ const user = userEvent.setup();
+ renderWithProviders( );
+
+ const sheet = await openActionSheet(user);
+ await user.click(sheet.getByText("Run Tests"));
+
+ expect(await screen.findByTestId("api-dialog")).toHaveTextContent("Refresh Test Data");
+ await waitFor(() =>
+ expect(screen.queryByText("Test suite actions")).not.toBeInTheDocument()
+ );
+ expect(screen.getByTestId("api-dialog")).toBeInTheDocument();
+ });
+
+ it("opens the edit drawer pre-filled with the selected custom suite", async () => {
+ layoutState.isMobile = true;
+ routerState.query = { reportId: "custom-1" };
+ const user = userEvent.setup();
+ renderWithProviders( );
+
+ const sheet = await openActionSheet(user);
+ await user.click(sheet.getByText("Edit Suite"));
+
+ const drawer = await screen.findByTestId("drawer-edit");
+ expect(drawer).toHaveTextContent("My Custom Suite");
+ });
+
+ it("reloads the suite list from the sheet", async () => {
+ layoutState.isMobile = true;
+ const user = userEvent.setup();
+ renderWithProviders( );
+
+ const sheet = await openActionSheet(user);
+ await user.click(sheet.getByText("Reload suite list"));
+
+ // the sheet hands off on its exit transition, so the call lands a beat later
+ await waitFor(() => expect(apiState.refetch).toHaveBeenCalled());
+ });
+});
diff --git a/tests/components/CippComponents/CippSankey.test.jsx b/tests/components/CippComponents/CippSankey.test.jsx
new file mode 100644
index 000000000000..85735229d851
--- /dev/null
+++ b/tests/components/CippComponents/CippSankey.test.jsx
@@ -0,0 +1,61 @@
+import React from "react";
+import { describe, it, expect, vi } from "vitest";
+import { renderWithProviders, settingsWith } from "../../test-utils";
+import { createTheme } from "../../../src/theme";
+
+// jsdom gives nivo's responsive wrapper a 0×0 parent, so nothing paints — capture the
+// props instead and assert on the dark/light decisions they encode.
+const captured = vi.hoisted(() => ({ props: null }));
+vi.mock("@nivo/sankey", () => ({
+ ResponsiveSankey: (props) => {
+ captured.props = props;
+ return null;
+ },
+}));
+
+vi.mock("../../../src/hooks/use-breakpoint", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useIsMobileLayout: () => false,
+}));
+
+import { CippSankey } from "../../../src/components/CippComponents/CippSankey";
+
+const data = {
+ nodes: [
+ { id: "Users", nodeColor: "#f97316" },
+ { id: "MFA", nodeColor: "#22c55e" },
+ ],
+ links: [{ source: "Users", target: "MFA", value: 5 }],
+};
+
+const darkTheme = createTheme({
+ colorPreset: "orange",
+ direction: "ltr",
+ paletteMode: "dark",
+ contrast: "high",
+});
+
+describe("CippSankey theming", () => {
+ // The app resolves currentTheme "browser" to the OS preference when building the MUI
+ // theme, so the *setting* can say "browser" while the page paints dark. Deciding
+ // darkness from the setting made the chart multiply its ribbons over a dark card —
+ // composited to black, i.e. an invisible chart until the user toggled the theme.
+ it("follows the painted palette, not the theme setting", () => {
+ renderWithProviders( , {
+ theme: darkTheme,
+ settings: settingsWith({ currentTheme: { value: "browser", label: "Browser default" } }),
+ });
+
+ expect(captured.props.linkBlendMode).toBe("lighten");
+ expect(captured.props.labelTextColor).toBe("#ffffff");
+ });
+
+ it("keeps multiply-over-white on an actually light page", () => {
+ renderWithProviders( , {
+ settings: settingsWith({ currentTheme: { value: "browser", label: "Browser default" } }),
+ });
+
+ expect(captured.props.linkBlendMode).toBe("multiply");
+ expect(captured.props.labelTextColor).toBe("#000000");
+ });
+});
diff --git a/tests/components/CippComponents/CippSettingsSideBar.test.jsx b/tests/components/CippComponents/CippSettingsSideBar.test.jsx
new file mode 100644
index 000000000000..2362102662a9
--- /dev/null
+++ b/tests/components/CippComponents/CippSettingsSideBar.test.jsx
@@ -0,0 +1,46 @@
+import React from 'react'
+import { describe, it, expect, vi } from 'vitest'
+import { screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { useForm } from 'react-hook-form'
+import { renderWithProviders } from '../../test-utils'
+
+vi.mock('../../../src/api/ApiCall', async () => (await import('../../mocks/api-call')).apiCallMock())
+import { api, getResult, postResult } from '../../mocks/api-call'
+
+import { CippSettingsSideBar } from '../../../src/components/CippComponents/CippSettingsSideBar'
+
+const meResult = getResult({ data: { clientPrincipal: { userDetails: 'admin@contoso.com' } } })
+api.get = meResult
+
+// handleSaveChanges posts an explicit field allowlist, a preference missing from it saves
+// as a silent no-op ("Settings saved successfully" toast, nothing stored)
+const Harness = () => {
+ const formcontrol = useForm({
+ defaultValues: {
+ user: { label: 'Current User', value: 'admin@contoso.com' },
+ tableViewMode: { value: 'table', label: 'Always classic table' },
+ tablePageSize: { value: '50', label: '50' },
+ },
+ })
+ return
+}
+
+describe('CippSettingsSideBar save allowlist', () => {
+ it('Save Changes posts tableViewMode with the settings blob', async () => {
+ const user = userEvent.setup()
+ api.post = postResult()
+ renderWithProviders( )
+
+ await user.click(await screen.findByRole('button', { name: /save changes/i }))
+
+ await waitFor(() => expect(api.post.mutate).toHaveBeenCalled())
+ const payload = api.post.mutate.mock.calls[0][0]
+ expect(payload.data.user).toBe('admin@contoso.com')
+ expect(payload.data.currentSettings.tableViewMode).toEqual({
+ value: 'table',
+ label: 'Always classic table',
+ })
+ expect(payload.data.currentSettings.tablePageSize).toEqual({ value: '50', label: '50' })
+ })
+})
diff --git a/tests/components/CippComponents/CippTabPicker.stories.jsx b/tests/components/CippComponents/CippTabPicker.stories.jsx
new file mode 100644
index 000000000000..4303a3b6cfab
--- /dev/null
+++ b/tests/components/CippComponents/CippTabPicker.stories.jsx
@@ -0,0 +1,162 @@
+import React from 'react'
+import { within, userEvent, waitFor, expect } from 'storybook/test'
+import { Box, Stack, Typography } from '@mui/material'
+import { CippTabPicker } from '../../../src/components/CippComponents/CippTabPicker'
+import { TabNavigationContext } from '../../../src/layouts/tab-navigation-context'
+import { shrinkToPhoneViewport } from '../../viewport'
+
+// tenant/manage — the worst group in the app for label length. 30 characters on the longest.
+const TABS = [
+ { label: 'Edit Tenant', path: '/tenant/manage/edit', icon: 'Settings' },
+ { label: 'Manage Drift', path: '/tenant/manage/drift', icon: 'Sync' },
+ { label: 'Configuration Backup', path: '/tenant/manage/backup', icon: 'Backup' },
+ { label: 'Applied Standards Report', path: '/tenant/manage/standards', icon: 'Assessment' },
+ {
+ label: 'Policies and Settings Deployed',
+ path: '/tenant/manage/policies',
+ icon: 'Assessment',
+ },
+]
+
+const withTabs =
+ (currentPath = '/tenant/manage/policies', tabs = TABS) =>
+ (Story) => (
+ {},
+ actions: [],
+ claim: () => {},
+ release: () => {},
+ isActionCornerClaimed: false,
+ }}
+ >
+
+
+ )
+
+export default {
+ title: 'Components/CippComponents/CippTabPicker',
+ component: CippTabPicker,
+ tags: ['autodocs'],
+}
+
+// The default, and what every tabbed page gets: one full-width control in the slot the
+// desktop tab bar occupied. Same control, same place, every page.
+export const BlockAtPhoneWidth = {
+ decorators: [withTabs()],
+ render: () => (
+
+
+
+ ),
+ play: async ({ canvasElement, step }) => {
+ const onAPhone = await shrinkToPhoneViewport()
+ const canvas = within(canvasElement)
+ const picker = canvas.getByRole('button', { name: /switch view/i })
+
+ await step('the trigger names the current view', async () => {
+ await expect(picker).toHaveAccessibleName('Policies and Settings Deployed switch view')
+ })
+
+ if (!onAPhone) return
+
+ await step('the longest label in the app fits without widening the page', async () => {
+ const host = canvasElement.querySelector('[data-testid="block-host"]')
+ await waitFor(() => expect(host.scrollWidth).toBeLessThanOrEqual(host.clientWidth))
+ // full width of the gutter box, so the control is unmistakably a control
+ const style = getComputedStyle(host)
+ const content =
+ host.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight)
+ await expect(picker.getBoundingClientRect().width).toBeGreaterThan(content - 1)
+ })
+
+ // Heading clothes: the chevron rides beside the text like a title's disclosure
+ // affordance, not pinned to the far edge like a form field's.
+ await step('the chevron sits beside the label, not at the far edge', async () => {
+ const chevron = picker.querySelector('svg:last-of-type')
+ const labelEl = within(picker).getByText('Policies and Settings Deployed')
+ const gapToLabel = chevron.getBoundingClientRect().left - labelEl.getBoundingClientRect().right
+ await expect(gapToLabel).toBeLessThan(24)
+ })
+ },
+}
+
+// The one exception: HeaderedTabbedLayout's title row is empty on its right half below md,
+// so the picker rides there and navigation costs no vertical space at all.
+export const CompactInTitleRow = {
+ decorators: [withTabs()],
+ render: () => (
+
+
+
+
+ Contoso Manufacturing Holdings GmbH
+
+
+
+
+
+ ),
+ play: async ({ canvasElement, step }) => {
+ const onAPhone = await shrinkToPhoneViewport()
+ if (!onAPhone) return
+ const canvas = within(canvasElement)
+ const picker = canvas.getByRole('button', { name: /switch view/i })
+
+ await step('a 30-char label beside a long title does not widen the row', async () => {
+ const host = canvasElement.querySelector('[data-testid="title-row-host"]')
+ await waitFor(() => expect(host.scrollWidth).toBeLessThanOrEqual(host.clientWidth))
+ // and it stays a control rather than eating the heading's half of the row
+ await expect(picker.getBoundingClientRect().width).toBeLessThanOrEqual(
+ host.clientWidth / 2 + 1
+ )
+ })
+ },
+}
+
+// A single destination is not navigation — View Group and View Device have one tab each.
+export const SingleTabRendersNothing = {
+ decorators: [withTabs('/identity/groups/group', [TABS[0]])],
+ render: () => (
+
+
+
+ ),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement)
+ await expect(canvas.queryByRole('button', { name: /switch view/i })).toBeNull()
+ },
+}
+
+export const OpensTheSheet = {
+ decorators: [withTabs('/tenant/manage/edit')],
+ render: () => (
+
+
+
+ ),
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement)
+
+ // The trigger names the current view and so does its row in the sheet — scope to the
+ // sheet, or every current-tab query matches twice.
+ let sheet
+
+ await step('every destination is a full-width row, none scrolled off an edge', async () => {
+ await userEvent.click(canvas.getByRole('button', { name: /switch view/i }))
+ const body = within(document.body)
+ await waitFor(() => expect(body.getByText('Views')).toBeInTheDocument())
+ sheet = within(body.getByText('Views').closest('.MuiDrawer-paper'))
+ await expect(sheet.getByText('Configuration Backup')).toBeInTheDocument()
+ await expect(sheet.getByText('Policies and Settings Deployed')).toBeInTheDocument()
+ })
+
+ await step('the current view is checked', async () => {
+ const current = sheet.getByText('Edit Tenant').closest('[role="button"]')
+ await expect(current).toHaveClass('Mui-selected')
+ })
+ },
+}
diff --git a/tests/components/CippComponents/CippUserSwitcher.test.jsx b/tests/components/CippComponents/CippUserSwitcher.test.jsx
new file mode 100644
index 000000000000..a26931fe95f4
--- /dev/null
+++ b/tests/components/CippComponents/CippUserSwitcher.test.jsx
@@ -0,0 +1,104 @@
+import React from "react";
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { renderWithProviders } from "../../test-utils";
+
+const layoutState = vi.hoisted(() => ({ isMobile: false }));
+vi.mock("../../../src/hooks/use-breakpoint", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useIsMobileLayout: () => layoutState.isMobile,
+}));
+
+// Stable identities (tests/mocks/api-call.js): fresh objects per call re-render forever
+const routerState = vi.hoisted(() => {
+ const router = {
+ push: () => {},
+ pathname: "/identity/administration/users/user",
+ query: { userId: "user-1", tenantFilter: "contoso.com" },
+ };
+ return { router };
+});
+vi.mock("next/router", () => ({ useRouter: () => routerState.router }));
+
+const apiState = vi.hoisted(() => ({
+ result: { isFetching: false, isSuccess: true, data: { Results: [] } },
+}));
+vi.mock("../../../src/api/ApiCall", () => ({
+ ApiGetCall: () => apiState.result,
+}));
+
+import { CippUserSwitcher } from "../../../src/components/CippComponents/CippUserSwitcher";
+
+const users = [
+ { id: "user-1", displayName: "Ada Lovelace", userPrincipalName: "ada@contoso.com" },
+ { id: "user-2", displayName: "Grace Hopper", userPrincipalName: "grace@contoso.com" },
+ { id: "user-3", displayName: "Alan Turing", userPrincipalName: "alan@contoso.com" },
+];
+
+const renderSwitcher = () =>
+ renderWithProviders(
+
+ );
+
+describe("CippUserSwitcher", () => {
+ beforeEach(() => {
+ layoutState.isMobile = false;
+ routerState.router.push = vi.fn();
+ apiState.result = { isFetching: false, isSuccess: true, data: { Results: users } };
+ });
+
+ it("keeps the visible name in the accessible name", () => {
+ renderSwitcher();
+ expect(
+ screen.getByRole("button", { name: /Ada Lovelace switch user/i })
+ ).toBeInTheDocument();
+ });
+
+ it("switches only the userId, keeping route and tenant", async () => {
+ const user = userEvent.setup();
+ renderSwitcher();
+
+ await user.click(screen.getByRole("button", { name: /switch user/i }));
+ await user.click(await screen.findByText("Grace Hopper"));
+
+ expect(routerState.router.push).toHaveBeenCalledWith({
+ pathname: "/identity/administration/users/user",
+ query: { userId: "user-2", tenantFilter: "contoso.com" },
+ });
+ });
+
+ it("treats picking the current user as a no-op", async () => {
+ const user = userEvent.setup();
+ renderSwitcher();
+
+ await user.click(screen.getByRole("button", { name: /switch user/i }));
+ // the popover lists the current user too — pick the row, not the trigger's own text
+ const rows = await screen.findAllByText("Ada Lovelace");
+ await user.click(rows[rows.length - 1]);
+
+ expect(routerState.router.push).not.toHaveBeenCalled();
+ });
+
+ it("filters by name or UPN", async () => {
+ const user = userEvent.setup();
+ renderSwitcher();
+
+ await user.click(screen.getByRole("button", { name: /switch user/i }));
+ await user.type(await screen.findByPlaceholderText(/search users/i), "alan@");
+
+ const list = screen.getByRole("list");
+ expect(within(list).getByText("Alan Turing")).toBeInTheDocument();
+ expect(within(list).queryByText("Grace Hopper")).not.toBeInTheDocument();
+ });
+
+ it("uses the bottom sheet on mobile", async () => {
+ layoutState.isMobile = true;
+ const user = userEvent.setup();
+ renderSwitcher();
+
+ await user.click(screen.getByRole("button", { name: /switch user/i }));
+ const sheet = (await screen.findByText("Grace Hopper")).closest(".MuiDrawer-paper");
+ expect(sheet).not.toBeNull();
+ });
+});
diff --git a/tests/components/CippComponents/SecureScoreCard.test.jsx b/tests/components/CippComponents/SecureScoreCard.test.jsx
index bd47877ebda4..9184157d519e 100644
--- a/tests/components/CippComponents/SecureScoreCard.test.jsx
+++ b/tests/components/CippComponents/SecureScoreCard.test.jsx
@@ -1,7 +1,19 @@
import React from 'react'
import { screen } from '@testing-library/react'
import { renderWithTheme } from '../../test-utils'
-import { SecureScoreCard } from '../../../src/components/CippComponents/SecureScoreCard'
+
+// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook
+const layoutState = vi.hoisted(() => ({ isMobile: false }))
+vi.mock('../../../src/hooks/use-breakpoint', () => ({
+ useIsMobileLayout: () => layoutState.isMobile,
+ useIsTabletLayout: () => false,
+ useTableViewMode: () => 'table',
+}))
+
+import {
+ SecureScoreCard,
+ secureScoreAxisProps,
+} from '../../../src/components/CippComponents/SecureScoreCard'
const scoreData = [
{ createdDateTime: '2026-07-01T00:00:00Z', currentScore: 40, maxScore: 100 },
@@ -10,6 +22,36 @@ const scoreData = [
]
describe('SecureScoreCard', () => {
+ beforeEach(() => {
+ layoutState.isMobile = false
+ })
+
+ // recharts reads its axis children's props without mounting them, so there is no element to
+ // assert against — the config is exported and tested directly.
+ const ticks = ['Jul 1', 'Jul 15', 'Jul 29']
+
+ // interval 0 draws a label for every point. Thirteen dates fit across a desktop card and
+ // overlap into one smear at 390px, which is what "Jul 27Jul 28Jul 29" looks like.
+ it('labels every point on desktop', () => {
+ const axis = secureScoreAxisProps({ isMobile: false, ticks })
+
+ expect(axis.x.interval).toBe(0)
+ expect(axis.x.ticks).toBe(ticks)
+ expect(axis.x.tick.fontSize).toBe(12)
+ expect(axis.y.width).toBeUndefined()
+ })
+
+ it('hands x-axis spacing back to recharts on a narrow chart', () => {
+ const axis = secureScoreAxisProps({ isMobile: true, ticks })
+
+ expect(axis.x.interval).toBe('preserveStartEnd')
+ expect(axis.x.ticks).toBeUndefined()
+ expect(axis.x.minTickGap).toBeGreaterThan(5)
+ expect(axis.x.tick.fontSize).toBeLessThan(12)
+ // and the y-axis gutter narrows so the plot keeps the width it has
+ expect(axis.y.width).toBeLessThan(40)
+ })
+
it('does not trigger the recharts zero-size warning on first render', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
diff --git a/tests/components/CippFormPages/CippFormPage.test.jsx b/tests/components/CippFormPages/CippFormPage.test.jsx
index 62e38136817a..be8fbbe205a9 100644
--- a/tests/components/CippFormPages/CippFormPage.test.jsx
+++ b/tests/components/CippFormPages/CippFormPage.test.jsx
@@ -1,90 +1,109 @@
-import React from 'react'
-import { screen, waitFor } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import { useForm } from 'react-hook-form'
-import { renderWithProviders } from '../../test-utils'
-import CippFormPage from '../../../src/components/CippFormPages/CippFormPage'
-import CippFormComponent from '../../../src/components/CippComponents/CippFormComponent'
+import React from "react";
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { screen } from "@testing-library/react";
+import { useForm } from "react-hook-form";
+import { renderWithProviders } from "../../test-utils";
-// capture the submit payload, network layer is not under test here
-const apiState = vi.hoisted(() => ({ mutate: null }))
+// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook
+const layoutState = vi.hoisted(() => ({ isMobile: false }));
+vi.mock("../../../src/hooks/use-breakpoint", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useIsMobileLayout: () => layoutState.isMobile,
+ useIsTabletLayout: () => false,
+}));
-vi.mock('../../../src/api/ApiCall', () => ({
- ApiPostCall: () => ({
- mutate: apiState.mutate,
- isPending: false,
- isSuccess: false,
- isIdle: true,
- isError: false,
- isFetching: false,
- data: undefined,
- reset: () => {},
- }),
- // CippApiResults polls job status through ApiGetCall, keep it inert
- ApiGetCall: () => ({
- isSuccess: false,
- isPending: true,
- isFetching: false,
- isError: false,
- data: undefined,
- }),
-}))
+// Stable identities: CippFormPage has a useEffect keyed on the router object itself that
+// resets the form — a fresh object per call re-renders forever (tests/mocks/api-call.js)
+const routerState = vi.hoisted(() => {
+ const router = { push: () => {}, back: () => {}, query: {} };
+ return { push: router.push, pathname: "/cipp/sam-roles", router };
+});
+vi.mock("next/navigation", () => ({
+ useRouter: () => routerState.router,
+ usePathname: () => routerState.pathname,
+ useSearchParams: () => new URLSearchParams(""),
+}));
+vi.mock("next/router", () => ({
+ useRouter: () => routerState.router,
+}));
-const Harness = ({ defaultValues = { displayName: '', notes: '' }, ...pageProps }) => {
- const formControl = useForm({ mode: 'onChange', defaultValues })
+// Stable identities: a fresh object per call re-renders forever (tests/mocks/api-call.js)
+const idle = vi.hoisted(() => ({
+ isSuccess: false,
+ isFetching: false,
+ isPending: false,
+ isError: false,
+ isIdle: true,
+ data: undefined,
+ mutate: () => {},
+ reset: () => {},
+ refetch: () => {},
+}));
+vi.mock("../../../src/api/ApiCall", () => ({
+ ApiGetCall: () => idle,
+ ApiPostCall: () => idle,
+ ApiGetCallWithPagination: () => ({ ...idle, fetchNextPage: () => {} }),
+}));
+
+import { TabbedLayout } from "../../../src/layouts/TabbedLayout";
+import CippFormPage from "../../../src/components/CippFormPages/CippFormPage";
+
+const tabOptions = [
+ { label: "SAM App Roles", path: "/cipp/sam-roles" },
+ { label: "SSO", path: "/cipp/sso" },
+];
+
+const Harness = (formPageProps) => {
+ const formControl = useForm({ mode: "onChange" });
return (
-
-
+
-
-
- )
-}
+ postUrl="/api/x"
+ queryKey="x"
+ {...formPageProps}
+ >
+ form content
+
+
+ );
+};
-describe('CippFormPage', () => {
+describe("CippFormPage title vs the mobile tab picker", () => {
beforeEach(() => {
- apiState.mutate = vi.fn()
- })
-
- it('renders the page type, title, and form children', () => {
- renderWithProviders( )
+ layoutState.isMobile = false;
+ routerState.pathname = "/cipp/sam-roles";
+ });
- expect(screen.getByRole('heading', { name: 'Add - User' })).toBeInTheDocument()
- expect(screen.getByRole('textbox', { name: 'Display Name' })).toBeInTheDocument()
- expect(screen.getByRole('button', { name: 'Submit' })).toBeDisabled()
- })
+ // Same defect class as CippPageCard: the picker trigger already says "SAM App Roles"
+ // right above this h4, so the page opened with its own name printed twice in a row.
+ it("stands its title down when the picker already says it", () => {
+ layoutState.isMobile = true;
+ renderWithProviders( );
- it('renders a custom page type and hides it on request', () => {
- const { unmount } = renderWithProviders( )
- expect(screen.getByRole('heading', { name: 'Edit - User' })).toBeInTheDocument()
- unmount()
+ expect(screen.getAllByText("SAM App Roles")).toHaveLength(1);
+ expect(
+ screen.queryByRole("heading", { level: 4, name: "SAM App Roles" })
+ ).not.toBeInTheDocument();
+ });
- renderWithProviders( )
- expect(screen.getByRole('heading', { name: 'User' })).toBeInTheDocument()
- })
+ // With the page-type prefix the rendered text is "Add - SAM App Roles", which is not what
+ // the picker says — so it still renders.
+ it("keeps a title the prefix makes different", () => {
+ layoutState.isMobile = true;
+ renderWithProviders( );
- it('submits form values to postUrl and strips empty fields', async () => {
- const user = userEvent.setup()
- renderWithProviders( )
+ expect(
+ screen.getByRole("heading", { level: 4, name: "Add - SAM App Roles" })
+ ).toBeInTheDocument();
+ });
- await user.type(screen.getByRole('textbox', { name: 'Display Name' }), 'John Doe')
- const submit = screen.getByRole('button', { name: 'Submit' })
- await waitFor(() => {
- expect(submit).toBeEnabled()
- })
- await user.click(submit)
+ it("keeps its title on desktop", () => {
+ renderWithProviders( );
- await waitFor(() => {
- expect(apiState.mutate).toHaveBeenCalledTimes(1)
- })
- // notes stayed '', removeEmpty drops it from the payload
- expect(apiState.mutate).toHaveBeenCalledWith({
- url: '/api/AddUser',
- data: { displayName: 'John Doe' },
- })
- })
-})
+ expect(screen.getByRole("heading", { level: 4, name: "SAM App Roles" })).toBeInTheDocument();
+ });
+});
diff --git a/tests/components/CippPdf/CippPdfPreview.test.jsx b/tests/components/CippPdf/CippPdfPreview.test.jsx
new file mode 100644
index 000000000000..575499cace03
--- /dev/null
+++ b/tests/components/CippPdf/CippPdfPreview.test.jsx
@@ -0,0 +1,128 @@
+import React from 'react'
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { screen } from '@testing-library/react'
+import { renderWithProviders } from '../../test-utils'
+
+// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook
+const layoutState = vi.hoisted(() => ({ isMobile: false }))
+vi.mock('../../../src/hooks/use-breakpoint', () => ({
+ useIsMobileLayout: () => layoutState.isMobile,
+ useIsTabletLayout: () => false,
+ useTableViewMode: () => 'table',
+}))
+
+// Building a real PDF in jsdom is neither possible nor the point: what is under test is which
+// branch renders and what it hands the user. Stable identities — a fresh object per call
+// re-renders forever.
+const pdfState = vi.hoisted(() => ({
+ instance: { loading: false, error: null, url: 'blob:http://localhost/report-1', blob: { size: 1_572_864 } },
+ viewerProps: null,
+}))
+vi.mock('@react-pdf/renderer', () => ({
+ PDFViewer: (props) => {
+ pdfState.viewerProps = props
+ return {props.children}
+ },
+ usePDF: () => [pdfState.instance],
+}))
+
+import { CippPdfPreview } from '../../../src/components/CippPdf/CippPdfPreview'
+
+const doc = document
+
+const render = (props = {}) =>
+ renderWithProviders(
+
+ {doc}
+
+ )
+
+describe('CippPdfPreview', () => {
+ beforeEach(() => {
+ layoutState.isMobile = false
+ pdfState.viewerProps = null
+ pdfState.instance = {
+ loading: false,
+ error: null,
+ url: 'blob:http://localhost/report-1',
+ blob: { size: 1_572_864 },
+ }
+ })
+
+ it('renders the embedded viewer on desktop', () => {
+ render()
+ expect(screen.getByTestId('pdf-viewer')).toBeInTheDocument()
+ expect(screen.getByTestId('report-doc')).toBeInTheDocument()
+ expect(screen.queryByRole('link', { name: /open report/i })).not.toBeInTheDocument()
+ })
+
+ // title/fileName/viewerKey are ours, not react-pdf's — forwarding them would land unknown
+ // attributes on the iframe and warn.
+ it('does not leak its own props onto the desktop viewer', () => {
+ render({ style: { border: 'none' }, showToolbar: true, showDownload: true })
+ expect(pdfState.viewerProps).not.toHaveProperty('title')
+ expect(pdfState.viewerProps).not.toHaveProperty('fileName')
+ expect(pdfState.viewerProps).not.toHaveProperty('viewerKey')
+ expect(pdfState.viewerProps).not.toHaveProperty('showDownload')
+ expect(pdfState.viewerProps.showToolbar).toBe(true)
+ })
+
+ // iOS renders a PDF in an iframe as a fixed first-page preview that cannot be scrolled, so
+ // below md the document goes to the platform viewer instead of being embedded.
+ it('hands off to the platform viewer on mobile instead of embedding', () => {
+ layoutState.isMobile = true
+ render()
+
+ expect(screen.queryByTestId('pdf-viewer')).not.toBeInTheDocument()
+
+ const open = screen.getByRole('link', { name: /open report/i })
+ expect(open).toHaveAttribute('href', 'blob:http://localhost/report-1')
+ expect(open).toHaveAttribute('target', '_blank')
+ // a real anchor, not window.open in a handler — that is what popup blockers stop
+ expect(open.tagName).toBe('A')
+ })
+
+ // Six of the eight hosts already put a Download in their dialog actions; showing one here
+ // as well is exactly the duplicate that appeared on a phone.
+ it('offers no download of its own by default', () => {
+ layoutState.isMobile = true
+ render()
+
+ expect(screen.queryByRole('link', { name: /download/i })).not.toBeInTheDocument()
+ })
+
+ it('offers a download named after the report where the host has none', () => {
+ layoutState.isMobile = true
+ render({ showDownload: true })
+
+ const download = screen.getByRole('link', { name: /download/i })
+ expect(download).toHaveAttribute('download', 'Executive_Report.pdf')
+ expect(download).toHaveAttribute('href', 'blob:http://localhost/report-1')
+ })
+
+ it('names the report and its size', () => {
+ layoutState.isMobile = true
+ render()
+
+ expect(screen.getByText('Executive Report - Contoso')).toBeInTheDocument()
+ expect(screen.getByText(/1\.5 MB/)).toBeInTheDocument()
+ })
+
+ it('shows progress while the document is still building', () => {
+ layoutState.isMobile = true
+ pdfState.instance = { loading: true, error: null, url: null, blob: null }
+ render()
+
+ expect(screen.getByRole('progressbar')).toBeInTheDocument()
+ expect(screen.queryByRole('link', { name: /open report/i })).not.toBeInTheDocument()
+ })
+
+ it('surfaces a generation failure rather than an empty frame', () => {
+ layoutState.isMobile = true
+ pdfState.instance = { loading: false, error: 'boom', url: null, blob: null }
+ render()
+
+ expect(screen.getByText(/could not be generated/i)).toBeInTheDocument()
+ expect(screen.queryByRole('link', { name: /open report/i })).not.toBeInTheDocument()
+ })
+})
diff --git a/tests/components/CippPdf/ReportDialogActions.stories.jsx b/tests/components/CippPdf/ReportDialogActions.stories.jsx
new file mode 100644
index 000000000000..ec80d2f4fec8
--- /dev/null
+++ b/tests/components/CippPdf/ReportDialogActions.stories.jsx
@@ -0,0 +1,68 @@
+import React from 'react'
+import { within, waitFor, expect } from 'storybook/test'
+import { Box, Button, DialogActions, Typography } from '@mui/material'
+import { Download } from '@mui/icons-material'
+import { shrinkToPhoneViewport } from '../../viewport'
+
+/**
+ * The report dialogs' action row, reproduced — the dialogs themselves need too much data to
+ * mount. Below md the caption and two buttons cannot share a line at 390px, so the row stacks;
+ * this holds the contract that the buttons then span the same width as each other.
+ */
+const ActionsRow = () => (
+
+ :not(style) ~ :not(style)': { ml: { xs: 0, md: 1 } },
+ }}
+ >
+
+
+ Sections enabled: 7 of 9
+
+
+ } sx={{ minWidth: 140 }}>
+ Download PDF
+
+ Close
+
+
+)
+
+export default {
+ title: 'Components/CippPdf/ReportDialogActions',
+ tags: ['autodocs'],
+}
+
+export const StackedAtPhoneWidth = {
+ render: () => ,
+ play: async ({ canvasElement, step }) => {
+ const onAPhone = await shrinkToPhoneViewport()
+ if (!onAPhone) return
+ const canvas = within(canvasElement)
+ const host = canvasElement.querySelector('[data-testid="actions-host"]')
+
+ const primary = canvas.getByRole('button', { name: /download pdf/i })
+ const secondary = canvas.getByRole('button', { name: /^close$/i })
+
+ await step('the two buttons share one width and one left edge', async () => {
+ await waitFor(() => {
+ const a = primary.getBoundingClientRect()
+ const b = secondary.getBoundingClientRect()
+ expect(Math.abs(a.width - b.width)).toBeLessThanOrEqual(1)
+ expect(Math.abs(a.left - b.left)).toBeLessThanOrEqual(1)
+ expect(Math.abs(a.right - b.right)).toBeLessThanOrEqual(1)
+ })
+ })
+
+ await step('and nothing pushes the row wider than the screen', async () => {
+ await expect(host.scrollWidth).toBeLessThanOrEqual(host.clientWidth)
+ })
+ },
+}
diff --git a/tests/components/CippSettings/CippContainerManagement.test.jsx b/tests/components/CippSettings/CippContainerManagement.test.jsx
index 4a2527c37610..f2cbdebcee27 100644
--- a/tests/components/CippSettings/CippContainerManagement.test.jsx
+++ b/tests/components/CippSettings/CippContainerManagement.test.jsx
@@ -9,7 +9,10 @@ vi.mock('../../../src/api/ApiCall', async () => (await import('../../mocks/api-c
import { api, getResult, paginatedResult, postResult } from '../../mocks/api-call'
// stable references, fresh literals per call spin CippAutoComplete's mapping effect
-api.get = getResult()
+const statusGet = getResult()
+// status payload only answers its own url, the page's other GETs stay idle
+const idleGet = getResult({ isSuccess: false })
+api.get = (opts) => (opts.url === '/api/ExecContainerManagement' ? statusGet : idleGet)
api.paginated = paginatedResult()
api.post = postResult()
@@ -55,26 +58,26 @@ const ALERT_RE = /unsupported build from an unmerged branch/
describe('CippContainerManagement branch-build flagging', () => {
beforeEach(() => {
vi.clearAllMocks()
- api.get.data = undefined
+ statusGet.data = undefined
api.paginated.data = { pages: [{ Results: [] }] }
})
it('running pinned branch build chips the split tag, not Unknown', () => {
- api.get.data = { Results: statusResults('fix-sso-thing-a1b2c3d') }
+ statusGet.data = { Results: statusResults('fix-sso-thing-a1b2c3d') }
renderWithProviders( )
expect(screen.getByText(PINNED_PRETTY)).toBeInTheDocument()
expect(screen.queryByText('Unknown')).not.toBeInTheDocument()
})
it('running branch build shows the unsupported-build alert and seeds the picker with its tag', async () => {
- api.get.data = { Results: statusResults('feat-new-widget') }
+ statusGet.data = { Results: statusResults('feat-new-widget') }
renderWithProviders( )
expect(await screen.findByText(ALERT_RE)).toBeInTheDocument()
expect(screen.getByRole('combobox', { name: 'Release Channel' })).toHaveValue('feat-new-widget')
})
it('standard channel chips its friendly label and raises no branch alert', async () => {
- api.get.data = { Results: statusResults('dev') }
+ statusGet.data = { Results: statusResults('dev') }
renderWithProviders( )
expect(screen.getByText('Dev')).toBeInTheDocument()
// wait for the seed effect so the alert-absence check runs against the settled form
@@ -86,7 +89,7 @@ describe('CippContainerManagement branch-build flagging', () => {
it('unrecognized non-branch tag chips Unknown without raising the branch alert', async () => {
// bare version tag: not a valid channel, does not match BuildChannelPattern
- api.get.data = { Results: statusResults('8.0.1') }
+ statusGet.data = { Results: statusResults('8.0.1') }
renderWithProviders( )
expect(screen.getByText('Unknown')).toBeInTheDocument()
await waitFor(() => {
@@ -96,7 +99,7 @@ describe('CippContainerManagement branch-build flagging', () => {
})
it('picking a branch build raises the alert, switching back to a standard channel clears it', async () => {
- api.get.data = { Results: statusResults('latest') }
+ statusGet.data = { Results: statusResults('latest') }
api.paginated.data = { pages: [{ Results: channelListResults }] }
const user = userEvent.setup()
renderWithProviders( )
diff --git a/tests/components/CippSettings/CippPermissionReport.test.jsx b/tests/components/CippSettings/CippPermissionReport.test.jsx
new file mode 100644
index 000000000000..eb13775528d8
--- /dev/null
+++ b/tests/components/CippSettings/CippPermissionReport.test.jsx
@@ -0,0 +1,86 @@
+import React from "react";
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { renderWithProviders } from "../../test-utils";
+
+// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook
+const layoutState = vi.hoisted(() => ({ isMobile: false }));
+vi.mock("../../../src/hooks/use-breakpoint", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useIsMobileLayout: () => layoutState.isMobile,
+}));
+
+// Stable identities — a fresh object per call re-renders forever
+const idle = vi.hoisted(() => ({
+ isSuccess: false,
+ isFetching: false,
+ isPending: false,
+ isError: false,
+ data: undefined,
+ mutate: () => {},
+ reset: () => {},
+ refetch: () => {},
+}));
+vi.mock("../../../src/api/ApiCall", () => ({
+ ApiGetCall: () => idle,
+ ApiPostCall: () => idle,
+ ApiGetCallWithPagination: () => ({ ...idle, fetchNextPage: () => {} }),
+}));
+
+import { CippPermissionReport } from "../../../src/components/CippSettings/CippPermissionReport";
+
+const renderReport = () =>
+ renderWithProviders( {}} />);
+
+describe("CippPermissionReport report actions", () => {
+ beforeEach(() => {
+ layoutState.isMobile = false;
+ });
+
+ it("keeps the button row inline on desktop, with no FAB", () => {
+ renderReport();
+ expect(screen.getByRole("button", { name: /export report/i })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /page actions/i })).not.toBeInTheDocument();
+ });
+
+ // Three contained buttons stacked full-width at 390px read as a banner wall before any
+ // content — page-level utilities belong in the page-actions FAB sheet on mobile.
+ it("moves the buttons into the FAB sheet on mobile", async () => {
+ layoutState.isMobile = true;
+ const user = userEvent.setup();
+ renderReport();
+
+ const fab = screen.getByRole("button", { name: /page actions/i });
+ // not on the page until the sheet opens
+ expect(screen.queryByRole("button", { name: /export report/i })).not.toBeInTheDocument();
+
+ await user.click(fab);
+ expect(await screen.findByText("Report")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /export report/i })).toBeInTheDocument();
+ expect(screen.getByText(/import report/i)).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /paste report/i })).toBeInTheDocument();
+
+ // uniform with every other sheet action: list rows, not contained buttons in a sheet
+ expect(document.querySelector(".MuiDrawer-paper .MuiButton-contained")).toBeNull();
+ expect(
+ screen.getByRole("button", { name: /export report/i }).classList.contains("MuiListItemButton-root")
+ ).toBe(true);
+ });
+
+ // The sheet sits at modal + 1 — if a row tap didn't close it, the export dialog would
+ // open UNDERNEATH it. ListItemButton is a div[role=button], which the close selector
+ // originally missed.
+ it("closes the sheet when a row opens its dialog", async () => {
+ layoutState.isMobile = true;
+ const user = userEvent.setup();
+ renderReport();
+
+ await user.click(screen.getByRole("button", { name: /page actions/i }));
+ const exportRow = await screen.findByRole("button", { name: /export report/i });
+ await user.click(exportRow);
+
+ // keepMounted keeps rows in the DOM; closed means hidden
+ await vi.waitFor(() => expect(screen.getByText(/paste report/i)).not.toBeVisible());
+ });
+});
diff --git a/tests/components/CippTable/CIPPTableToptoolbar.test.jsx b/tests/components/CippTable/CIPPTableToptoolbar.test.jsx
index fcaa292b66f9..8c80d6516a45 100644
--- a/tests/components/CippTable/CIPPTableToptoolbar.test.jsx
+++ b/tests/components/CippTable/CIPPTableToptoolbar.test.jsx
@@ -220,14 +220,15 @@ describe('CIPPTableToptoolbar - preset list refresh', () => {
})
}, 30000)
+ // pageName '' (jsdom router is '/') means no persistence, the slot tests name their key
it('restores both persisted slots and discards garbage global values', async () => {
- renderGraphTable({}, {
+ renderGraphTable({ persistenceKey: 'SlotsTest' }, {
settings: settingsWith({
persistFilters: true,
setLastUsedFilter: vi.fn(),
lastUsedFilters: {
// legacy single-slot shape with a non-string global value
- '': { type: 'global', value: [{ id: 'department', value: 'IT' }], name: 'Legacy Garbage' },
+ SlotsTest: { type: 'global', value: [{ id: 'department', value: 'IT' }], name: 'Legacy Garbage' },
},
}),
})
@@ -241,13 +242,13 @@ describe('CIPPTableToptoolbar - preset list refresh', () => {
})
it('restores both persisted slots and discards new-shape garbage global values', async () => {
- renderGraphTable({}, {
+ renderGraphTable({ persistenceKey: 'SlotsTest' }, {
settings: settingsWith({
persistFilters: true,
setLastUsedFilter: vi.fn(),
lastUsedFilters: {
// new shape can carry the same non-string global garbage the legacy branch discards
- '': {
+ SlotsTest: {
graph: null,
table: { id: 'Garbage', name: 'Garbage', type: 'global', value: [{ id: 'department', value: 'IT' }] },
},
@@ -264,12 +265,12 @@ describe('CIPPTableToptoolbar - preset list refresh', () => {
})
it('restores a legacy column filter into the table slot', async () => {
- renderGraphTable({}, {
+ renderGraphTable({ persistenceKey: 'SlotsTest' }, {
settings: settingsWith({
persistFilters: true,
setLastUsedFilter: vi.fn(),
lastUsedFilters: {
- '': { type: 'column', value: [{ id: 'department', value: 'IT' }], name: 'IT only' },
+ SlotsTest: { type: 'column', value: [{ id: 'department', value: 'IT' }], name: 'IT only' },
},
}),
})
@@ -279,6 +280,96 @@ describe('CIPPTableToptoolbar - preset list refresh', () => {
expect(screen.getByRole('button', { name: 'Filters (1)' })).toBeInTheDocument()
})
+ // Regression: the restore effect used to key on getRequestData.isFetching, re-arming its
+ // 100ms timer on every fetch settle (once per page of an auto-paginated load) and
+ // overwriting whatever the user had just applied with the persisted filter.
+ it('does not clobber a user filter applied after the persisted one was restored', async () => {
+ const user = userEvent.setup()
+ renderGraphTable({ persistenceKey: 'SlotsTest' }, {
+ settings: settingsWith({
+ persistFilters: true,
+ setLastUsedFilter: vi.fn(),
+ lastUsedFilters: {
+ SlotsTest: { type: 'column', value: [{ id: 'department', value: 'IT' }], name: 'IT only' },
+ },
+ }),
+ })
+ // persisted "IT only" lands first
+ await waitFor(() => {
+ expect(screen.getByText('1-2 of 2')).toBeInTheDocument()
+ }, { timeout: 5000 })
+
+ // user switches to the other preset
+ await user.click(screen.getByRole('button', { name: /Filters/ }))
+ await user.click(await screen.findByRole('menuitem', { name: 'Sales only' }))
+ await waitFor(() => {
+ expect(screen.getByText('1-1 of 1')).toBeInTheDocument()
+ })
+
+ // well past the restore timer: the persisted filter must not come back
+ await new Promise((resolve) => setTimeout(resolve, 400))
+ expect(screen.getByText('1-1 of 1')).toBeInTheDocument()
+ }, 30000)
+
+ it('syncs the search box when a global preset is applied and cleared', async () => {
+ const user = userEvent.setup()
+ renderGraphTable({
+ filters: [{ filterName: 'Named Alice', value: 'alice', type: 'global' }],
+ })
+ await screen.findByText('1-3 of 3')
+
+ await user.click(screen.getByRole('button', { name: /Filters/ }))
+ await user.click(await screen.findByRole('menuitem', { name: 'Named Alice' }))
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText('Search...')).toHaveValue('alice')
+ })
+
+ // tapping the active preset again clears the slot — and the box with it
+ await user.click(screen.getByRole('button', { name: /Filters/ }))
+ await user.click(await screen.findByRole('menuitem', { name: 'Named Alice' }))
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText('Search...')).toHaveValue('')
+ })
+ }, 30000)
+
+ // filterList was state-initialised from the prop and never re-synced, so pages that
+ // compute `filters` asynchronously showed an empty preset list forever
+ it('picks up filters that arrive after the first render', async () => {
+ const user = userEvent.setup()
+ presetsResult = graphPresetResult
+
+ const LateFilters = () => {
+ const [filters, setFilters] = React.useState([])
+ return (
+ <>
+ setFilters(tablePresets)}>
+ load filters
+
+
+ >
+ )
+ }
+
+ renderWithProviders( )
+ await screen.findByText('1-3 of 3')
+
+ await user.click(screen.getByRole('button', { name: /Filters/ }))
+ expect(screen.queryByRole('menuitem', { name: 'IT only' })).toBeNull()
+ await user.keyboard('{Escape}')
+
+ await user.click(screen.getByRole('button', { name: 'load filters' }))
+ await user.click(screen.getByRole('button', { name: /Filters/ }))
+ expect(await screen.findByRole('menuitem', { name: 'IT only' })).toBeInTheDocument()
+ // the fetched graph preset is not lost when the prop-driven list arrives
+ expect(screen.getByRole('menuitem', { name: 'Widget View' })).toBeInTheDocument()
+ }, 30000)
+
it('renaming an applied graph preset keeps it marked active', async () => {
const user = userEvent.setup()
renderGraphTable()
@@ -298,3 +389,25 @@ describe('CIPPTableToptoolbar - preset list refresh', () => {
})
}, 30000)
})
+
+describe('CIPPTableToptoolbar desktop export', () => {
+ it('Export menu carries the row exports and opens the API response viewer', async () => {
+ const user = userEvent.setup()
+ renderWithProviders(
+
+ )
+ await screen.findByText('Users')
+
+ await user.click(screen.getByRole('button', { name: /Export/ }))
+ await screen.findByRole('menuitem', { name: 'Export to CSV' })
+ expect(screen.getByRole('menuitem', { name: 'Export to PDF' })).toBeInTheDocument()
+
+ await user.click(screen.getByRole('menuitem', { name: 'View API Response' }))
+ await screen.findByText('API Response')
+ })
+})
diff --git a/tests/components/CippTable/CippDataTable.stories.jsx b/tests/components/CippTable/CippDataTable.stories.jsx
index eed8a4c817f8..5c35b7d67eba 100644
--- a/tests/components/CippTable/CippDataTable.stories.jsx
+++ b/tests/components/CippTable/CippDataTable.stories.jsx
@@ -480,3 +480,40 @@ export const GraphBackedEditFilters = {
})
},
}
+
+// cached report column (membersCsv) wears the subTable header, no nested-table button. MRT renders no header cells in jsdom
+export const CachedReportColumns = {
+ args: {
+ title: 'Groups',
+ data: [{ id: 'parent-1', displayName: 'Finance', membersCsv: 'Jane, Bob' }],
+ simpleColumns: ['displayName', 'members'],
+ subTables: [
+ {
+ id: 'members',
+ header: 'Members',
+ label: 'View members',
+ cachedColumn: 'membersCsv',
+ table: {
+ title: 'Members of [displayName]',
+ api: { url: '/api/TestRelated', dataKey: 'Results' },
+ simpleColumns: ['displayName'],
+ },
+ },
+ ],
+ },
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement)
+
+ await step('cached csv column takes the subTable header', async () => {
+ await waitFor(() => {
+ expect(canvas.getByRole('columnheader', { name: /Members/ })).toBeVisible()
+ })
+ expect(canvas.queryByRole('columnheader', { name: /csv/i })).toBeNull()
+ })
+
+ await step('cell shows the cached value, no nested table button', async () => {
+ await expect(canvas.getByText('Jane, Bob')).toBeVisible()
+ expect(canvas.queryByRole('button', { name: 'View members' })).toBeNull()
+ })
+ },
+}
diff --git a/tests/components/CippTable/CippDataTable.test.jsx b/tests/components/CippTable/CippDataTable.test.jsx
index bdc5023185bf..20173be8bb03 100644
--- a/tests/components/CippTable/CippDataTable.test.jsx
+++ b/tests/components/CippTable/CippDataTable.test.jsx
@@ -1,8 +1,19 @@
import React from 'react'
-import { screen, waitFor } from '@testing-library/react'
+import { screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { renderWithProviders } from '../../test-utils'
import { CippDataTable } from '../../../src/components/CippTable/CippDataTable'
+import { resetOverlayHistory } from '../../../src/utils/overlay-history'
+
+vi.mock('../../../src/api/ApiCall', async () => (await import('../../mocks/api-call')).apiCallMock())
+import { api, paginatedResult } from '../../mocks/api-call'
+
+// idle keeps static-data tables on their data prop; the nested result is re-wrapped per call like react-query's tracked copy, the memo'd toolbar needs it to see selection
+const nestedRows = [{ id: 'child-1', displayName: 'Jane Doe' }]
+const nestedResult = paginatedResult(nestedRows)
+const idlePaginated = paginatedResult([], { isSuccess: false })
+api.paginated = (opts) => (opts?.url === '/api/TestRelated' ? { ...nestedResult } : idlePaginated)
const basicData = [
{ displayName: 'Alice Smith', mail: 'alice@contoso.com', department: 'IT', accountEnabled: true },
@@ -310,3 +321,677 @@ describe('CippDataTable', () => {
expect(container.querySelector('table')).not.toBeNull()
})
})
+
+// A card shows a title, subtitle and a few chips/details — on pages that never configured
+// an offCanvas the rest of the row used to be unreachable in card view.
+describe('CippDataTable card view without an offCanvas', () => {
+ const wideData = [
+ {
+ displayName: 'Alice Smith',
+ mail: 'alice@contoso.com',
+ department: 'IT',
+ jobTitle: 'Engineer',
+ city: 'Seattle',
+ country: 'US',
+ accountEnabled: true,
+ },
+ ]
+ const columns = ['displayName', 'mail', 'department', 'jobTitle', 'city', 'country']
+
+ it('opens an extended-info drawer from a card tap showing every shown column', async () => {
+ const user = userEvent.setup()
+ renderWithProviders(
+
+ )
+
+ await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument())
+ await user.click(screen.getByText('Alice Smith'))
+
+ // fields that never fit on the card are present in the drawer
+ await waitFor(() => expect(screen.getAllByText(/Engineer/).length).toBeGreaterThan(0))
+ expect(screen.getAllByText(/Seattle/).length).toBeGreaterThan(0)
+ })
+
+ // The test-detail pages render their own drawer body (offCanvas.children) — prepending
+ // the generic property list on top of it repeated Risk/Status above a body that already
+ // presents them.
+ it('lets a custom drawer body own the drawer, without the generic property list', async () => {
+ const user = userEvent.setup()
+ renderWithProviders(
+ rich detail body
,
+ }}
+ />
+ )
+
+ await waitFor(() =>
+ expect(screen.getByText('Applications do not have client secrets configured')).toBeInTheDocument()
+ )
+ await user.click(screen.getByText('Applications do not have client secrets configured'))
+
+ // scope to the drawer that holds the body — the toolbar's Edit Filters offcanvas is
+ // also a mounted .MuiDrawer-paper and sorts first in the DOM
+ const body = await screen.findByTestId('rich-body')
+ const drawer = body.closest('.MuiDrawer-paper')
+ expect(drawer.textContent).toContain('rich detail body')
+ // no generic property list stacked above the page's own body
+ expect(drawer.textContent).not.toMatch(/Risk/)
+ })
+
+ // Retired: the extended-info drawer's action buttons. Pages still carry `actions` in their
+ // offCanvas configs (the Users page spreads userActions in), and the config is spread onto
+ // the drawer — so the retirement has to survive the spread, not just the explicit prop.
+ it('keeps retired drawer actions out even when the page config carries them', async () => {
+ const user = userEvent.setup()
+ renderWithProviders(
+
+ )
+
+ await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument())
+ await user.click(screen.getByText('Alice Smith'))
+
+ // drawer is open (property list rendered) but the actions block is gone
+ await waitFor(() => expect(screen.getAllByText(/alice@contoso.com/).length).toBeGreaterThan(0))
+ expect(screen.queryByText('View User')).not.toBeInTheDocument()
+ })
+
+ it('formats fallback values the way their table cells do', async () => {
+ const user = userEvent.setup()
+ renderWithProviders(
+
+ )
+
+ await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument())
+ await user.click(screen.getByText('Alice Smith'))
+
+ // 'text' mode would flatten the boolean to the string "Yes"; the cell renderer uses an icon.
+ // Anchored: unanchored, this would also pass on "notcontoso.com" — and CodeQL flags it.
+ await waitFor(() => expect(screen.getAllByText(/^contoso\.com$/).length).toBeGreaterThan(0))
+ expect(screen.queryByText('Yes')).toBeNull()
+ })
+
+ it('spells out portal links instead of showing a bare icon', async () => {
+ const user = userEvent.setup()
+ renderWithProviders(
+
+ )
+
+ await waitFor(() => expect(screen.getByText('Contoso')).toBeInTheDocument())
+ await user.click(screen.getByText('Contoso'))
+
+ const link = await screen.findByRole('link', { name: /open portal/i })
+ expect(link).toHaveAttribute('href', 'https://admin.cloud.microsoft/?delegatedOrg=contoso')
+ expect(link).toHaveAttribute('target', '_blank')
+ })
+
+ it('links portal values on the card itself, scheme-less ones included', async () => {
+ renderWithProviders(
+
+ )
+
+ await waitFor(() => expect(screen.getByText('Contoso')).toBeInTheDocument())
+ // rendered on the card, without opening the drawer
+ const link = await screen.findByRole('link', { name: /open portal/i })
+ expect(link).toHaveAttribute('href', 'https://contoso-admin.sharepoint.com')
+ })
+
+ it('merges the page offCanvas fields with the remaining visible columns', async () => {
+ const user = userEvent.setup()
+ renderWithProviders(
+
+ )
+
+ await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument())
+ await user.click(screen.getByText('Alice Smith'))
+ await waitFor(() => expect(screen.getByText('User Details')).toBeInTheDocument())
+
+ // curated fields present, and the ones it left out are appended rather than dropped
+ expect(screen.getAllByText(/^alice@contoso\.com$/).length).toBeGreaterThan(0)
+ expect(screen.getAllByText(/Engineer/).length).toBeGreaterThan(0)
+ expect(screen.getAllByText(/Seattle/).length).toBeGreaterThan(0)
+ })
+
+ it('does not repeat a field that appears in both lists', async () => {
+ const user = userEvent.setup()
+ renderWithProviders(
+
+ )
+
+ await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument())
+ await user.click(screen.getByText('Alice Smith'))
+ await waitFor(() => expect(screen.getByText('User Details')).toBeInTheDocument())
+
+ // scoped to the drawer — the card behind it renders its own Department row
+ const drawer = screen.getByText('User Details').closest('.MuiDrawer-paper')
+ expect(drawer).not.toBeNull()
+ expect(within(drawer).getAllByText('Department').length).toBe(1)
+ })
+
+ it('leaves a page-supplied offCanvas in charge', async () => {
+ const user = userEvent.setup()
+ renderWithProviders(
+
+ )
+
+ await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument())
+ await user.click(screen.getByText('Alice Smith'))
+
+ // the page's own drawer opens — the fallback never substitutes for a configured one
+ await waitFor(() => expect(screen.getByText('User Details')).toBeInTheDocument())
+ expect(screen.getAllByText(/Seattle/).length).toBeGreaterThan(0)
+ })
+})
+
+// The offcanvas walks the rows with Prev/Next and reports "N of M". Both come from the
+// table's row model, and both used to be read from a mirror of it kept in state.
+describe('CippDataTable offcanvas row navigation', () => {
+ // Deliberately unsorted: the display order and the arrival order differ.
+ const people = [
+ { displayName: 'Carol Williams', mail: 'carol@contoso.com' },
+ { displayName: 'Alice Smith', mail: 'alice@contoso.com' },
+ { displayName: 'Bob Johnson', mail: 'bob@contoso.com' },
+ ]
+
+ // The Prev/Next bar and the position caption only render below md.
+ const useMobileViewport = () => {
+ const cache = new Map()
+ window.matchMedia = (query) => {
+ if (!cache.has(query)) {
+ cache.set(query, {
+ matches: query.includes('max-width'),
+ media: query,
+ onchange: null,
+ addListener: () => {},
+ removeListener: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ dispatchEvent: () => false,
+ })
+ }
+ return cache.get(query)
+ }
+ }
+
+ const Table = (props) => (
+
+ )
+
+ // Rows land from the API after the table has already mounted — the normal case.
+ const AsyncTable = (props) => {
+ const [data, setData] = React.useState([])
+ return (
+ <>
+ setData(people)}>
+ Load rows
+
+
+ >
+ )
+ }
+
+ beforeEach(() => {
+ useMobileViewport()
+ })
+
+ afterEach(() => {
+ resetOverlayHistory()
+ delete window.matchMedia
+ })
+
+ it('counts rows that arrived after the table mounted', async () => {
+ const user = userEvent.setup()
+ renderWithProviders( )
+
+ await user.click(screen.getByRole('button', { name: 'Load rows' }))
+ await waitFor(() => expect(screen.getByText('Carol Williams')).toBeInTheDocument())
+ await user.click(screen.getByText('Carol Williams'))
+
+ expect(await screen.findByText('1 of 3')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /prev/i })).toBeDisabled()
+ expect(screen.getByRole('button', { name: /next/i })).toBeEnabled()
+ })
+
+ it('numbers rows in the order they are shown, not the order they arrived', async () => {
+ const user = userEvent.setup()
+ renderWithProviders(
+
+ )
+
+ // Sorted, Carol is last on screen — so she is the last row, with nowhere to go next.
+ await waitFor(() => expect(screen.getByText('Carol Williams')).toBeInTheDocument())
+ await user.click(screen.getByText('Carol Williams'))
+
+ expect(await screen.findByText('3 of 3')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /next/i })).toBeDisabled()
+ })
+
+ it('counts only the rows left after a search', async () => {
+ const user = userEvent.setup()
+ const withTwoBobs = [
+ ...people,
+ { displayName: 'Bob Marley', mail: 'bob.marley@contoso.com' },
+ ]
+ renderWithProviders(
+
+ )
+
+ await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument())
+ await user.type(screen.getByRole('searchbox', { name: 'Search' }), 'bob')
+ await waitFor(() => expect(screen.queryByText('Alice Smith')).not.toBeInTheDocument())
+
+ await user.click(screen.getByText('Bob Marley'))
+
+ // the sorted model is built from the FILTERED rows, so the search narrows the walk
+ // too: two Bobs, not four people.
+ expect(await screen.findByText('2 of 2')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /next/i })).toBeDisabled()
+ expect(screen.getByRole('button', { name: /prev/i })).toBeEnabled()
+ })
+
+ it('steps to the next row as displayed', async () => {
+ const user = userEvent.setup()
+ renderWithProviders(
+
+ )
+
+ await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument())
+ await user.click(screen.getByText('Alice Smith'))
+ expect(await screen.findByText('1 of 3')).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: /next/i }))
+
+ // Bob follows Alice on screen; Carol is where the raw arrival order would have landed.
+ expect(await screen.findByText('2 of 3')).toBeInTheDocument()
+ // scoped by the drawer's own heading — the toolbar renders a filter Drawer too
+ const drawer = screen.getByText('Extended Info').closest('.MuiDrawer-paper')
+ expect(within(drawer).getByText('bob@contoso.com')).toBeInTheDocument()
+ })
+})
+
+// the narrow-table height measurement reads viewport-relative positions, so the toggle
+// aligns the card surface with the scrolling ancestor's top before the table flips in
+describe('CippDataTable cards->table toggle scroll', () => {
+ const useMobileViewport = () => {
+ const cache = new Map()
+ window.matchMedia = (query) => {
+ if (!cache.has(query)) {
+ cache.set(query, {
+ matches: query.includes('max-width'),
+ media: query,
+ onchange: null,
+ addListener: () => {},
+ removeListener: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ dispatchEvent: () => false,
+ })
+ }
+ return cache.get(query)
+ }
+ }
+
+ beforeEach(() => {
+ useMobileViewport()
+ })
+
+ afterEach(() => {
+ delete window.matchMedia
+ })
+
+ it('keeps a mid-page table in view instead of yanking the page to the top', async () => {
+ const user = userEvent.setup()
+ renderWithProviders(
+
+
+
+ )
+ await waitFor(() => expect(screen.getByTestId('cipp-card-view')).toBeInTheDocument())
+
+ // surface sits below the scroller's viewport top, page already scrolled
+ const scroller = screen.getByTestId('scroller')
+ const surface = screen.getByTestId('cipp-card-view')
+ scroller.getBoundingClientRect = () => ({ top: 64 })
+ surface.getBoundingClientRect = () => ({ top: 300 })
+ scroller.scrollTop = 120
+
+ await user.click(screen.getByRole('button', { name: 'Toggle table view' }))
+
+ // prior scroll plus the surface's offset from the scroller viewport top
+ expect(scroller.scrollTop).toBe(120 + (300 - 64))
+ })
+})
+
+describe('CippDataTable subTables', () => {
+ const parentRows = [{ id: 'parent-1', displayName: 'Finance' }]
+ // live nested table, the shape groups/index.js ships
+ const nestedTable = {
+ title: 'Related for [displayName]',
+ api: { url: '/api/TestRelated', dataKey: 'Results' },
+ simpleColumns: ['displayName'],
+ viewMode: 'cards',
+ }
+
+ it('injects a button column that opens a nested table', async () => {
+ const user = userEvent.setup()
+ renderWithProviders(
+
+ )
+
+ await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument())
+ await user.click(screen.getByRole('button', { name: 'View' }))
+
+ const dialog = await screen.findByRole('dialog')
+ await waitFor(() => {
+ expect(within(dialog).getByText('Related for Finance')).toBeInTheDocument()
+ })
+ await waitFor(() => {
+ expect(within(dialog).getByText('Jane Doe')).toBeInTheDocument()
+ })
+ })
+
+ it('runs nested row and bulk actions with the parent row attached', async () => {
+ const rowFn = vi.fn()
+ const user = userEvent.setup()
+ renderWithProviders(
+
+ )
+
+ await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument())
+ await user.click(screen.getByRole('button', { name: 'View' }))
+
+ const dialog = await screen.findByRole('dialog')
+ await waitFor(() => expect(within(dialog).getByText('Jane Doe')).toBeInTheDocument())
+
+ await user.click(within(dialog).getByRole('button', { name: 'Row actions' }))
+ await user.click(await screen.findByText('Remove'))
+
+ // the row sheet hands the action off to its exit transition
+ await waitFor(() => {
+ expect(rowFn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: 'child-1',
+ displayName: 'Jane Doe',
+ parent: expect.objectContaining({ id: 'parent-1', displayName: 'Finance' }),
+ }),
+ expect.anything(),
+ expect.anything()
+ )
+ })
+
+ rowFn.mockClear()
+ await user.click(within(dialog).getByRole('button', { name: 'Select' }))
+ await user.click(within(dialog).getByRole('checkbox', { name: 'Select Jane Doe' }))
+ await user.click(within(dialog).getByRole('button', { name: 'Actions' }))
+ await user.click(await screen.findByText('Remove'))
+
+ await waitFor(() => {
+ expect(rowFn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: 'child-1',
+ parent: expect.objectContaining({ id: 'parent-1' }),
+ }),
+ expect.anything(),
+ expect.anything()
+ )
+ })
+ })
+
+ it('replaces a data column that shares the subTable id', async () => {
+ const user = userEvent.setup()
+ renderWithProviders(
+
+ )
+
+ await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument())
+ await user.click(screen.getByRole('button', { name: 'View' }))
+
+ const dialog = await screen.findByRole('dialog')
+ await waitFor(() => {
+ expect(within(dialog).getByText('Jane Doe')).toBeInTheDocument()
+ })
+ expect(within(dialog).queryByText('stale')).not.toBeInTheDocument()
+ })
+
+ it('does not show a subTable column unless it is listed in simpleColumns', async () => {
+ renderWithProviders(
+
+ )
+
+ await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument())
+ expect(screen.queryByRole('button', { name: 'View' })).not.toBeInTheDocument()
+ })
+
+ it('shows cachedColumn instead of the nested table button when that field is on the data', async () => {
+ renderWithProviders(
+
+ )
+
+ await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument())
+ expect(screen.queryByRole('button', { name: 'View members' })).not.toBeInTheDocument()
+ expect(screen.getByText('Jane, Bob')).toBeInTheDocument()
+ })
+
+ it('still shows the nested table button when cachedColumn is configured but missing from the data', async () => {
+ renderWithProviders(
+
+ )
+
+ await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument())
+ expect(screen.getByRole('button', { name: 'View members' })).toBeInTheDocument()
+ })
+
+ it('shows the nested table button when cachedColumn exists but is empty (live API shape)', async () => {
+ renderWithProviders(
+
+ )
+
+ await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument())
+ expect(screen.getByRole('button', { name: 'View members' })).toBeInTheDocument()
+ })
+
+ it('renders a declarative nested cardButton from table config', async () => {
+ const user = userEvent.setup()
+ renderWithProviders(
+
+ )
+
+ await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument())
+ await user.click(screen.getByRole('button', { name: 'View' }))
+
+ const nested = await screen.findByRole('dialog')
+ const addButton = await within(nested).findByRole('button', { name: 'Add Members' })
+ await user.click(addButton)
+
+ expect(await screen.findByText('Add Members for Finance?')).toBeInTheDocument()
+ })
+})
diff --git a/tests/components/CippTable/CippDataTableButton.stories.jsx b/tests/components/CippTable/CippDataTableButton.stories.jsx
index 87a7fb897397..b348d2de5625 100644
--- a/tests/components/CippTable/CippDataTableButton.stories.jsx
+++ b/tests/components/CippTable/CippDataTableButton.stories.jsx
@@ -1,3 +1,4 @@
+import { http, HttpResponse } from 'msw'
import { within, expect, userEvent, waitFor } from 'storybook/test'
import CippDataTableButton from '../../../src/components/CippTable/CippDataTableButton'
@@ -54,3 +55,52 @@ export const EmptyData = {
data: null,
},
}
+
+export const LiveNestedTable = {
+ parameters: {
+ msw: {
+ handlers: [
+ http.get('/api/TestRelated', () =>
+ HttpResponse.json({
+ Results: [
+ { id: 'rel-1', displayName: 'Related one' },
+ { id: 'rel-2', displayName: 'Related two' },
+ ],
+ })
+ ),
+ http.post('/api/ExecTestRelated', () => HttpResponse.json({ Results: 'ok' })),
+ ],
+ },
+ },
+ args: {
+ row: { id: 'parent-1', displayName: 'Finance' },
+ label: 'View',
+ title: 'Related for [displayName]',
+ queryKey: 'related-[id]',
+ api: {
+ url: '/api/TestRelated',
+ data: { someId: '[id]' },
+ dataKey: 'Results',
+ },
+ simpleColumns: ['displayName'],
+ actions: [
+ {
+ label: 'Remove',
+ type: 'POST',
+ url: '/api/ExecTestRelated',
+ data: { childId: 'id', parentId: 'parent.id' },
+ confirmText: 'Remove [displayName] from [parent.displayName]?',
+ },
+ ],
+ },
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement)
+ await step('opens a live nested table on click', async () => {
+ await userEvent.click(canvas.getByRole('button', { name: 'View' }))
+ const root = within(document.body)
+ await waitFor(() => {
+ expect(root.getByRole('dialog')).toBeVisible()
+ })
+ })
+ },
+}
diff --git a/tests/components/CippTable/CippDataTableButton.test.jsx b/tests/components/CippTable/CippDataTableButton.test.jsx
index c42bbd76de0c..3a8172f07229 100644
--- a/tests/components/CippTable/CippDataTableButton.test.jsx
+++ b/tests/components/CippTable/CippDataTableButton.test.jsx
@@ -3,8 +3,22 @@ import { screen, within, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '../../test-utils'
import CippDataTableButton from '../../../src/components/CippTable/CippDataTableButton'
+import { ApiGetCallWithPagination } from '../../../src/api/ApiCall'
+import { api, paginatedResult } from '../../mocks/api-call'
+
+vi.mock('../../../src/api/ApiCall', async () => (await import('../../mocks/api-call')).apiCallMock())
+
+const idlePaginated = paginatedResult([], { isSuccess: false })
+const relatedRows = [{ id: 'rel-1', name: 'Related one' }]
+const relatedResult = paginatedResult(relatedRows)
describe('CippDataTableButton', () => {
+ beforeEach(() => {
+ ApiGetCallWithPagination.mockClear()
+ api.paginated = (opts) =>
+ opts?.url === '/api/TestRelated' ? relatedResult : idlePaginated
+ })
+
it('shows item count and opens dialog on click', async () => {
const user = userEvent.setup()
renderWithProviders(
@@ -80,4 +94,57 @@ describe('CippDataTableButton', () => {
expect(button).toHaveTextContent('No items')
expect(button).toBeDisabled()
})
+
+ it('does not fetch live related data until the button is clicked', async () => {
+ const user = userEvent.setup()
+ const parentRow = { id: 'parent-1', displayName: 'Finance' }
+
+ renderWithProviders(
+
+ )
+
+ expect(screen.getByRole('button', { name: 'View' })).toBeEnabled()
+ expect(
+ ApiGetCallWithPagination.mock.calls.some((call) => call[0]?.url === '/api/TestRelated')
+ ).toBe(false)
+
+ await user.click(screen.getByRole('button', { name: 'View' }))
+
+ const dialog = await screen.findByRole('dialog')
+ expect(dialog).toBeInTheDocument()
+ await waitFor(() => {
+ expect(
+ ApiGetCallWithPagination.mock.calls.some((call) => call[0]?.url === '/api/TestRelated')
+ ).toBe(true)
+ })
+
+ const relatedCall = ApiGetCallWithPagination.mock.calls.find(
+ (call) => call[0]?.url === '/api/TestRelated'
+ )
+ expect(relatedCall[0].data.someId).toBe('parent-1')
+ expect(relatedCall[0].queryKey).toBe('related-parent-1')
+ })
+
+ it('disables the live button when condition is false', () => {
+ renderWithProviders(
+ row.id === 'other'}
+ api={{ url: '/api/TestRelated', dataKey: 'Results' }}
+ />
+ )
+ expect(screen.getByRole('button', { name: 'View' })).toBeDisabled()
+ })
})
diff --git a/tests/components/CippTable/CippDiagnosticsFilter.test.jsx b/tests/components/CippTable/CippDiagnosticsFilter.test.jsx
new file mode 100644
index 000000000000..b12e89ea8492
--- /dev/null
+++ b/tests/components/CippTable/CippDiagnosticsFilter.test.jsx
@@ -0,0 +1,47 @@
+import React from 'react'
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { renderWithProviders } from '../../test-utils'
+import CippDiagnosticsFilter from '../../../src/components/CippTable/CippDiagnosticsFilter'
+
+// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook
+const layoutState = vi.hoisted(() => ({ isMobile: false }))
+vi.mock('../../../src/hooks/use-breakpoint', () => ({
+ useIsMobileLayout: () => layoutState.isMobile,
+ useIsTabletLayout: () => false,
+ useTableViewMode: () => 'table',
+}))
+
+// Stable identities: a fresh object per call changes on every render and spins a loop.
+const idleGet = vi.hoisted(() => ({ data: [], isFetching: false, isSuccess: true }))
+const idlePost = vi.hoisted(() => ({ mutate: () => {}, isPending: false }))
+const idlePaginated = vi.hoisted(() => ({ data: undefined, isFetching: false }))
+vi.mock('../../../src/api/ApiCall', () => ({
+ ApiGetCall: () => idleGet,
+ ApiPostCall: () => idlePost,
+ ApiGetCallWithPagination: () => idlePaginated,
+}))
+
+beforeEach(() => {
+ layoutState.isMobile = false
+})
+
+describe('CippDiagnosticsFilter', () => {
+ // `rows` is a DOM attribute, so this cannot come from a responsive sx value.
+ // MUI renders a hidden shadow textarea beside the real one; only the real one carries
+ // the rows attribute this test is about.
+ const queryBox = (container) =>
+ Array.from(container.querySelectorAll('textarea')).find((el) => el.hasAttribute('rows'))
+
+ it('shortens the KQL box on a phone', () => {
+ layoutState.isMobile = true
+ const { container } = renderWithProviders( {}} />)
+
+ expect(queryBox(container)).toHaveAttribute('rows', '6')
+ })
+
+ it('keeps twelve rows on desktop', () => {
+ const { container } = renderWithProviders( {}} />)
+
+ expect(queryBox(container)).toHaveAttribute('rows', '12')
+ })
+})
diff --git a/tests/components/CippTable/CippGraphExplorerFilter.test.jsx b/tests/components/CippTable/CippGraphExplorerFilter.test.jsx
index 497382cdcaee..49df07503eb8 100644
--- a/tests/components/CippTable/CippGraphExplorerFilter.test.jsx
+++ b/tests/components/CippTable/CippGraphExplorerFilter.test.jsx
@@ -276,4 +276,48 @@ describe('CippGraphExplorerFilter', () => {
expect(onSubmitFilter.mock.calls[0][0]).toEqual({ version: 'beta' })
})
})
+
+ // Seeding from endpointFilter moved out of the render body (it updated the subscribed
+ // Controller mid-render, which the browser reports as "Cannot update a component while
+ // rendering a different component"). These cover the behaviour that move had to preserve —
+ // the warning itself doesn't reproduce under jsdom, so it can't be asserted here.
+ describe('endpointFilter prop', () => {
+ it('seeds the endpoint field from the prop', async () => {
+ renderWithProviders(
+
+ )
+
+ await waitFor(() => {
+ expect(screen.getByRole('textbox', { name: 'Endpoint' })).toHaveValue('users')
+ })
+ })
+
+ it('submits the seeded endpoint', async () => {
+ const onSubmitFilter = vi.fn()
+ const user = userEvent.setup()
+ renderWithProviders(
+
+ )
+ await waitFor(() => {
+ expect(screen.getByRole('textbox', { name: 'Endpoint' })).toHaveValue('users')
+ })
+
+ await user.click(screen.getByRole('button', { name: 'Apply Filter' }))
+ await waitFor(() => {
+ expect(onSubmitFilter).toHaveBeenCalledTimes(1)
+ })
+ expect(onSubmitFilter.mock.calls[0][0]).toMatchObject({ endpoint: 'users' })
+ })
+
+ it('leaves the endpoint field empty when no endpointFilter is given', async () => {
+ renderWithProviders( )
+ await waitFor(() => {
+ expect(screen.getByRole('textbox', { name: 'Endpoint' })).toHaveValue('')
+ })
+ })
+ })
})
diff --git a/tests/components/CippTable/CippMobileCardList.stories.jsx b/tests/components/CippTable/CippMobileCardList.stories.jsx
new file mode 100644
index 000000000000..f23120ccf7b0
--- /dev/null
+++ b/tests/components/CippTable/CippMobileCardList.stories.jsx
@@ -0,0 +1,423 @@
+import React from 'react'
+import { within, expect, userEvent, waitFor } from 'storybook/test'
+import { Box, Button } from '@mui/material'
+import { Add, Block, Delete, Edit } from '@mui/icons-material'
+import { CippDataTable } from '../../../src/components/CippTable/CippDataTable'
+import { SettingsProvider } from '../../../src/contexts/settings-context'
+import { shrinkToPhoneViewport, growToDesktopViewport } from '../../viewport'
+
+// most stories force cards via the viewMode prop; TableViewToggle shrinks the real viewport instead, since the toggle needs no explicit prop
+const users = [
+ {
+ id: 'u-1',
+ displayName: 'Alice Smith',
+ userPrincipalName: 'alice@contoso.com',
+ mail: 'alice@contoso.com',
+ department: 'IT',
+ jobTitle: 'Engineer',
+ accountEnabled: true,
+ createdDateTime: '2024-01-15T10:30:00Z',
+ },
+ {
+ id: 'u-2',
+ displayName: 'Bob Johnson',
+ userPrincipalName: 'bob@contoso.com',
+ mail: 'bob@contoso.com',
+ department: 'Sales',
+ jobTitle: 'Account Manager',
+ accountEnabled: true,
+ createdDateTime: '2024-03-22T14:15:00Z',
+ },
+ {
+ id: 'u-3',
+ displayName: 'Carol Williams',
+ userPrincipalName: 'carol@contoso.com',
+ mail: 'carol@contoso.com',
+ department: 'IT',
+ jobTitle: 'Director',
+ accountEnabled: false,
+ createdDateTime: '2023-11-01T09:00:00Z',
+ },
+]
+
+const manyUsers = Array.from({ length: 120 }, (_, i) => ({
+ id: `bulk-${i}`,
+ displayName: `User ${String(i).padStart(3, '0')}`,
+ userPrincipalName: `user${i}@contoso.com`,
+ mail: `user${i}@contoso.com`,
+ department: i % 2 ? 'Sales' : 'IT',
+ accountEnabled: i % 5 !== 0,
+}))
+
+const simpleColumns = ['displayName', 'userPrincipalName', 'accountEnabled', 'department', 'jobTitle']
+
+const actions = [
+ { label: 'Edit user', icon: , link: '/identity/administration/users/edit?id=[id]' },
+ { label: 'Block sign-in', icon: , type: 'POST', url: '/api/ExecDisableUser' },
+ { label: 'Delete user', icon: , type: 'POST', url: '/api/RemoveUser', color: 'error' },
+]
+
+export default {
+ title: 'Components/CippTable/CippMobileCardList',
+ component: CippDataTable,
+ tags: ['autodocs'],
+ args: {
+ viewMode: 'cards',
+ maxHeightOffset: '100px',
+ },
+ decorators: [
+ (Story) => (
+
+
+
+
+
+ ),
+ ],
+}
+
+export const Default = {
+ args: {
+ title: 'Users',
+ data: users,
+ simpleColumns,
+ actions,
+ },
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement)
+
+ await step('one card per row, titled by the name column', async () => {
+ await waitFor(() => expect(canvas.getByText('Alice Smith')).toBeInTheDocument())
+ expect(canvas.getByText('Carol Williams')).toBeInTheDocument()
+ // no in card view
+ expect(canvasElement.querySelector('table')).toBeNull()
+ })
+
+ await step('row kebab opens the action sheet with the page actions', async () => {
+ const kebabs = canvas.getAllByRole('button', { name: /row actions/i })
+ await userEvent.click(kebabs[0])
+ const body = within(document.body)
+ await waitFor(() => expect(body.getByText('Block sign-in')).toBeInTheDocument())
+ expect(body.getByText('Delete user')).toBeInTheDocument()
+ await userEvent.keyboard('{Escape}')
+ await waitFor(() => expect(body.queryByRole('dialog')).toBeNull())
+ })
+
+ await step('Filters opens the shared bottom sheet with the card fields', async () => {
+ const body = within(document.body)
+ await userEvent.click(canvas.getByRole('button', { name: 'Table options' }))
+ const filterSheet = await body.findByRole('dialog')
+ expect(within(filterSheet).getByText('Fields shown')).toBeInTheDocument()
+ await userEvent.keyboard('{Escape}')
+ await waitFor(() => expect(body.queryByRole('dialog')).toBeNull())
+ })
+ },
+}
+
+export const SelectMode = {
+ args: {
+ title: 'Users',
+ data: users,
+ simpleColumns,
+ actions,
+ },
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement)
+ await waitFor(() => expect(canvas.getByText('Alice Smith')).toBeInTheDocument())
+
+ await step('Select reveals per-card checkboxes and the bulk bar', async () => {
+ await userEvent.click(canvas.getByRole('button', { name: /select/i }))
+ const checkboxes = await canvas.findAllByRole('checkbox')
+ await userEvent.click(checkboxes[0])
+ await waitFor(() => expect(canvasElement.textContent).toContain('1 selected'))
+ })
+ },
+}
+
+export const PageActionsFab = {
+ args: {
+ title: 'Users',
+ data: users,
+ simpleColumns,
+ cardButton: (
+
+ }>
+ Add User
+
+ Bulk Add
+
+ ),
+ },
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement)
+ const body = within(document.body)
+ await waitFor(() => expect(canvas.getByText('Alice Smith')).toBeInTheDocument())
+
+ await step('cardButton children live behind the FAB', async () => {
+ await userEvent.click(body.getByRole('button', { name: 'Page actions' }))
+ await waitFor(() => expect(body.getByRole('button', { name: 'Add User' })).toBeInTheDocument())
+ expect(body.getByRole('button', { name: 'Bulk Add' })).toBeInTheDocument()
+ })
+ },
+}
+
+export const LoadMore = {
+ args: {
+ title: 'Users',
+ data: manyUsers,
+ simpleColumns: ['displayName', 'userPrincipalName', 'accountEnabled'],
+ },
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement)
+
+ await step('starts at the configured page size', async () => {
+ await waitFor(() => expect(canvasElement.textContent).toContain('Showing 25 of 120'), {
+ timeout: 10000,
+ })
+ })
+
+ await step('Load more grows the same list rather than paging', async () => {
+ await userEvent.click(canvas.getByRole('button', { name: /load 50 more/i }))
+ await waitFor(() => expect(canvasElement.textContent).toContain('Showing 75 of 120'))
+ // still one continuous list — no pagination control appeared
+ expect(canvas.queryByRole('button', { name: /go to next page/i })).toBeNull()
+ })
+ },
+}
+
+export const EmptyAfterFilter = {
+ args: {
+ title: 'Users',
+ data: users,
+ simpleColumns,
+ },
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement)
+ await waitFor(() => expect(canvas.getByText('Alice Smith')).toBeInTheDocument())
+
+ await step('a search with no matches offers to clear filters', async () => {
+ await userEvent.type(canvas.getByPlaceholderText(/search/i), 'zzzzz')
+ await waitFor(
+ () => expect(canvas.getByRole('button', { name: /clear filters/i })).toBeInTheDocument(),
+ { timeout: 3000 }
+ )
+ })
+ },
+}
+
+// The pair that proves "one table instance, two presentations": same data, same filter,
+// same resulting row set — only the presentation differs.
+const FILTERED_DEPARTMENT = 'IT'
+
+// The search box is debounced 200ms, so the filter landing is observed by waiting for the
+// excluded row to disappear — the included rows are on screen before the filter applies.
+const applyDepartmentSearch = async (canvas) => {
+ await userEvent.type(canvas.getByPlaceholderText(/search/i), FILTERED_DEPARTMENT)
+ await waitFor(() => expect(canvas.queryByText('Bob Johnson')).toBeNull(), { timeout: 5000 })
+ return [canvas.getByText('Alice Smith'), canvas.getByText('Carol Williams')]
+}
+
+export const DesktopTable = {
+ args: {
+ title: 'Users',
+ viewMode: 'table',
+ data: users,
+ simpleColumns,
+ },
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement)
+
+ await step('table view lists exactly the IT users', async () => {
+ expect(canvasElement.querySelector('table')).not.toBeNull()
+ const matched = await applyDepartmentSearch(canvas)
+ expect(matched).toHaveLength(2)
+ })
+ },
+}
+
+export const MobileCards = {
+ args: {
+ title: 'Users',
+ viewMode: 'cards',
+ data: users,
+ simpleColumns,
+ },
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement)
+
+ await step('card view yields the identical row set from the same state', async () => {
+ expect(canvasElement.querySelector('table')).toBeNull()
+ const matched = await applyDepartmentSearch(canvas)
+ expect(matched).toHaveLength(2)
+ })
+ },
+}
+
+export const TableViewToggle = {
+ render: () => (
+
+
+
+ ),
+ play: async ({ canvasElement }) => {
+ // shrink for real: a viewMode prop would also force cards, but hides the toggle (precedence rule)
+ const onAPhone = await shrinkToPhoneViewport()
+ const canvas = within(canvasElement)
+ await canvas.findByText('Alice Smith')
+ if (!onAPhone) {
+ return
+ }
+
+ // 'Alice Smith' renders in both branches, so the card list itself has to settle
+ await waitFor(() => expect(canvas.getByTestId('cipp-mobile-card-list')).toBeInTheDocument())
+
+ await userEvent.click(await canvas.findByRole('button', { name: 'Toggle table view' }))
+ await waitFor(() => {
+ expect(canvasElement.querySelector('table')).not.toBeNull()
+ expect(canvas.queryByTestId('cipp-mobile-card-list')).toBeNull()
+ })
+
+ // real MRT table mounts with the page's configured columns
+ await waitFor(() => expect(canvas.getAllByRole('columnheader').length).toBeGreaterThan(0))
+ const headerText = canvas.getAllByRole('columnheader').map((cell) => cell.textContent)
+ expect(headerText.some((text) => text.includes('Display Name'))).toBe(true)
+
+ // transient: the toggle never persists
+ const persisted = JSON.parse(window.localStorage.getItem('app.settings'))
+ expect(persisted.tableViewMode).toBe('auto')
+
+ // phone table bar: kebab opens the shared sheet, which carries refresh.
+ // MUI's Tooltip stamps the 'Refresh data' aria-label onto the wrapping span, so that's
+ // the queryable anchor for the desktop refresh button (the IconButton has no name of its own)
+ expect(canvasElement.querySelector('[aria-label="Refresh data"]')).toBeNull()
+ const optionsButton = canvas.getByRole('button', { name: 'Table options' })
+ await userEvent.click(optionsButton)
+ const filterSheet = await within(document.body).findByRole('dialog')
+ expect(within(filterSheet).getByText('Fields shown')).toBeInTheDocument()
+ expect(within(filterSheet).getByText('Reset all filters')).toBeInTheDocument()
+ expect(within(filterSheet).getByText('Refresh data')).toBeInTheDocument()
+ // the sheet owns page size on phones, current size marked active
+ expect(within(filterSheet).getByText('Rows per page')).toBeInTheDocument()
+ const activeSize = within(filterSheet).getByText('25').closest('.MuiChip-root')
+ expect(activeSize.className).toContain('MuiChip-filled')
+ await userEvent.keyboard('{Escape}')
+ await waitFor(() => expect(within(document.body).queryByRole('dialog')).toBeNull())
+
+ // same aria-label, now the desktop toolbar's "way back" button
+ await userEvent.click(canvas.getByRole('button', { name: 'Toggle table view' }))
+ await waitFor(() => expect(canvas.getByTestId('cipp-mobile-card-list')).toBeInTheDocument())
+ },
+}
+
+export const TableViewToggleWithActions = {
+ // render ignores the meta's default args (viewMode: 'cards' would hide the toggle button)
+ render: () => (
+
+
+ }>
+ Add User
+
+
+ }
+ />
+
+ ),
+ play: async ({ canvasElement }) => {
+ const onAPhone = await shrinkToPhoneViewport()
+ const canvas = within(canvasElement)
+ const body = within(document.body)
+ await canvas.findByText('Alice Smith')
+ if (!onAPhone) {
+ return
+ }
+
+ await waitFor(() => expect(canvas.getByTestId('cipp-mobile-card-list')).toBeInTheDocument())
+
+ await userEvent.click(await canvas.findByRole('button', { name: 'Toggle table view' }))
+ await waitFor(() => {
+ expect(canvasElement.querySelector('table')).not.toBeNull()
+ expect(canvas.queryByTestId('cipp-mobile-card-list')).toBeNull()
+ })
+
+ // narrow table view: cardButton lives behind the page actions FAB, absent from the canvas until opened
+ expect(canvas.queryByRole('button', { name: 'Add User' })).toBeNull()
+ const fab = await body.findByRole('button', { name: 'Page actions' })
+
+ await userEvent.click(fab)
+ await waitFor(() => expect(body.getByRole('button', { name: 'Add User' })).toBeInTheDocument())
+ },
+}
+
+export const TableViewToggleBulkActionsInHeader = {
+ // render ignores the meta's default args, same reason as the sibling toggle stories
+ render: () => (
+
+
+
+ ),
+ play: async ({ canvasElement }) => {
+ const onAPhone = await shrinkToPhoneViewport()
+ const canvas = within(canvasElement)
+ await canvas.findByText('Alice Smith')
+ if (!onAPhone) {
+ return
+ }
+
+ await waitFor(() => expect(canvas.getByTestId('cipp-mobile-card-list')).toBeInTheDocument())
+ await userEvent.click(await canvas.findByRole('button', { name: 'Toggle table view' }))
+ await waitFor(() => {
+ expect(canvasElement.querySelector('table')).not.toBeNull()
+ expect(canvas.queryByTestId('cipp-mobile-card-list')).toBeNull()
+ })
+
+ const firstRow = await waitFor(() => {
+ const row = canvasElement.querySelector('tbody tr')
+ expect(row).not.toBeNull()
+ return row
+ })
+ await userEvent.click(within(firstRow).getByRole('checkbox'))
+
+ // the mostly-empty header row is where the narrow toolbar's selection UI lands
+ const header = canvasElement.querySelector('.MuiCardHeader-root')
+ await waitFor(() => {
+ expect(within(header).getByText(/rows selected/)).toBeInTheDocument()
+ expect(within(header).getByRole('button', { name: 'Bulk Actions' })).toBeInTheDocument()
+ })
+ // exactly one Bulk Actions button on screen — it moved, it did not duplicate
+ expect(canvas.getAllByRole('button', { name: 'Bulk Actions' })).toHaveLength(1)
+
+ await userEvent.click(within(header).getByRole('button', { name: 'Bulk Actions' }))
+ const body = within(document.body)
+ await waitFor(() => expect(body.getByText('Delete user')).toBeInTheDocument())
+ await userEvent.keyboard('{Escape}')
+ },
+}
+
+export const DesktopBulkActionsStayInToolbar = {
+ args: {
+ title: 'Users',
+ viewMode: 'table',
+ data: users,
+ simpleColumns,
+ actions,
+ },
+ play: async ({ canvasElement }) => {
+ await growToDesktopViewport()
+ const canvas = within(canvasElement)
+ await waitFor(() => expect(canvasElement.querySelector('table')).not.toBeNull())
+ await canvas.findByText('Alice Smith')
+
+ const firstRow = canvasElement.querySelector('tbody tr')
+ await userEvent.click(within(firstRow).getByRole('checkbox'))
+
+ await waitFor(() => expect(canvas.getByRole('button', { name: 'Bulk Actions' })).toBeInTheDocument())
+ // the header exists (title-only, no cardButton on this story) but never received the portal
+ const header = canvasElement.querySelector('.MuiCardHeader-root')
+ expect(within(header).queryByRole('button', { name: 'Bulk Actions' })).toBeNull()
+ },
+}
diff --git a/tests/components/CippTable/CippMobileCardList.test.jsx b/tests/components/CippTable/CippMobileCardList.test.jsx
new file mode 100644
index 000000000000..32d26d258999
--- /dev/null
+++ b/tests/components/CippTable/CippMobileCardList.test.jsx
@@ -0,0 +1,368 @@
+import React from 'react'
+import { vi } from 'vitest'
+import { screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { Button } from '@mui/material'
+import { renderWithProviders, settingsWith } from '../../test-utils'
+
+// jsdom matchMedia never matches, so this overrides only useIsNarrowForTables for the FAB pivot, useTableViewMode stays real
+const narrowState = vi.hoisted(() => ({ narrow: false }))
+vi.mock('../../../src/hooks/use-breakpoint', async (importOriginal) => {
+ const actual = await importOriginal()
+ return { ...actual, useIsNarrowForTables: () => narrowState.narrow }
+})
+
+import { CippDataTable } from '../../../src/components/CippTable/CippDataTable'
+
+// wide enough that full mode overflows into "+N more fields"
+const users = [
+ {
+ displayName: 'Alice Smith',
+ userPrincipalName: 'alice@contoso.com',
+ department: 'IT',
+ jobTitle: 'Engineer',
+ city: 'Seattle',
+ country: 'US',
+ accountEnabled: true,
+ },
+]
+const columns = [
+ 'displayName',
+ 'userPrincipalName',
+ 'department',
+ 'jobTitle',
+ 'city',
+ 'country',
+ 'accountEnabled',
+]
+
+// no viewMode prop, so settings.tableViewMode='cards' forces cards but leaves the toggle allowed
+const renderCards = (settings = {}, componentProps = {}) =>
+ renderWithProviders(
+ ,
+ { settings: settingsWith({ tableViewMode: 'cards', ...settings }) }
+ )
+
+describe('CippMobileCardList card anatomy', () => {
+ it('shows the slotted anatomy: overflow counter, secondary slot as bare text', async () => {
+ renderCards()
+ await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument())
+ // details cap at 3 of the 4 remaining columns -> 1 overflow
+ expect(screen.getByText(/more field/)).toBeInTheDocument()
+ // secondary slot is bare text, its column label never renders
+ expect(screen.queryByText('User Principal Name')).not.toBeInTheDocument()
+ })
+})
+
+describe('CippMobileCardList status chips', () => {
+ // The identity/device/custom test tables: Result went to the chips row but Risk fell to
+ // the detail rows — two chips organised by two different systems on one card, and the
+ // detail-grid "High" said nothing about what was high.
+ it('keeps Result and Risk together in the chips row, and labels the mute one', async () => {
+ renderWithProviders(
+ ,
+ { settings: settingsWith({ tableViewMode: 'cards' }) }
+ )
+ await waitFor(() =>
+ expect(screen.getByText('Tenant has M365 Copilot prerequisites')).toBeInTheDocument()
+ )
+
+ const passed = screen.getByText('Passed')
+ const high = screen.getByText('High')
+ // both chips share one container — Risk is not off in the details grid
+ expect(high.closest('.MuiStack-root')).toBe(passed.closest('.MuiStack-root'))
+ // "High" alone doesn't say what is high; "Passed" speaks for itself
+ expect(screen.getByText('Risk')).toBeInTheDocument()
+ expect(screen.queryByText('Result')).not.toBeInTheDocument()
+ })
+})
+
+describe('CippMobileCardList table view toggle', () => {
+ afterEach(() => {
+ narrowState.narrow = false
+ })
+
+ it('opens the table view and the way back restores cards, never touching settings', async () => {
+ // narrow viewport: the round trip must hand back the card view intact
+ narrowState.narrow = true
+ const user = userEvent.setup()
+ const handleUpdate = vi.fn()
+ const { container } = renderCards({ handleUpdate })
+ await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument())
+ expect(screen.getByTestId('cipp-mobile-card-list')).toBeInTheDocument()
+ expect(screen.getByText('Department')).toBeInTheDocument()
+ expect(screen.getByText(/more field/)).toBeInTheDocument()
+
+ // jsdom renders no MRT header/row text, so just check the table mounts and cards unmount
+ await user.click(screen.getByRole('button', { name: 'Toggle table view' }))
+ await waitFor(() => expect(container.querySelector('table')).not.toBeNull())
+ expect(screen.queryByTestId('cipp-mobile-card-list')).not.toBeInTheDocument()
+
+ // same aria-label, now the desktop toolbar's "way back" button
+ await user.click(screen.getByRole('button', { name: 'Toggle table view' }))
+ await waitFor(() => expect(screen.getByTestId('cipp-mobile-card-list')).toBeInTheDocument())
+
+ // full card content is back: detail rows and the overflow counter
+ expect(screen.getByText('Department')).toBeInTheDocument()
+ expect(screen.getByText(/more field/)).toBeInTheDocument()
+
+ // transient: the view toggle never persists
+ expect(handleUpdate).not.toHaveBeenCalled()
+ })
+
+ it('the toggled table keeps every configured column visible', async () => {
+ narrowState.narrow = true
+ const user = userEvent.setup()
+ renderCards()
+ await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument())
+
+ await user.click(screen.getByRole('button', { name: 'Toggle table view' }))
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Columns' })).toBeInTheDocument())
+ await user.click(screen.getByRole('button', { name: 'Columns' }))
+
+ // Columns menu reads table.getAllColumns(), unaffected by the virtualized header row
+ const menu = within(screen.getAllByRole('menu')[0])
+ const checkbox = (name) => within(menu.getByRole('menuitem', { name })).getByRole('checkbox')
+ for (const name of [
+ 'Display Name',
+ 'User Principal Name',
+ 'Account Enabled',
+ 'Department',
+ 'Job Title',
+ 'City',
+ 'Country',
+ ]) {
+ expect(checkbox(name)).toBeChecked()
+ }
+ })
+
+ it('a Fields shown toggle in the shared filter sheet changes what the card renders', async () => {
+ const user = userEvent.setup()
+ renderCards()
+ await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument())
+ // department is a detail row on the card before the toggle
+ expect(screen.getByText('Department')).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'Table options' }))
+ await screen.findByText('Fields shown')
+ // the sheet is a portal appended to body, so its entry sorts after the card's label
+ await user.click(screen.getAllByText('Department').at(-1))
+ await user.click(screen.getByRole('button', { name: 'Done' }))
+
+ await waitFor(() => expect(screen.queryByText('Department')).not.toBeInTheDocument())
+ })
+
+ // "Fields shown" is a checkbox per column — a dozen rows on a wide table — so anything
+ // after it starts a long scroll down. The table utilities (refresh, export, reset) are what
+ // people open this sheet for far more often than field toggles.
+ it('puts the table utilities above the Fields shown list, not below it', async () => {
+ const user = userEvent.setup()
+ renderCards()
+ await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument())
+
+ await user.click(screen.getByRole('button', { name: 'Table options' }))
+ const fields = await screen.findByText('Fields shown')
+ const refresh = screen.getByText('Refresh data')
+
+ // DOCUMENT_POSITION_FOLLOWING = 4: fields comes after refresh in the DOM
+ expect(refresh.compareDocumentPosition(fields) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(4)
+ expect(
+ screen.getByText('Reset all filters').compareDocumentPosition(fields) &
+ Node.DOCUMENT_POSITION_FOLLOWING
+ ).toBe(4)
+ })
+
+ it('an explicit viewMode prop hides the toggle button', async () => {
+ renderWithProviders(
+ ,
+ { settings: settingsWith() }
+ )
+ await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument())
+ expect(screen.queryByRole('button', { name: 'Toggle table view' })).not.toBeInTheDocument()
+ })
+
+ // Regression: the cards branch and the table branch are two alternating CIPPTableToptoolbar
+ // instances (only one mounts at a time), so activeFilters/searchValue/restoredFiltersRef used
+ // to live in the toolbar's own useState and reset on every flip. The table-branch kebab is
+ // unreachable here (mdDown from useMediaQuery never matches in jsdom, and useCompactMode stays
+ // false since offsetWidth/scrollWidth are always 0) — reopening the sheet after the round trip
+ // is the observable proxy for "state survived the two remounts".
+ it('an applied preset and its badge survive a flip to table and back', async () => {
+ narrowState.narrow = true
+ const user = userEvent.setup()
+ const presetFilters = [
+ { filterName: 'IT department', value: [{ id: 'department', value: 'IT' }], type: 'column' },
+ ]
+ renderCards({}, { filters: presetFilters })
+ await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument())
+
+ await user.click(screen.getByRole('button', { name: 'Table options' }))
+ const sheet = await screen.findByRole('dialog')
+ await user.click(within(sheet).getByText('IT department'))
+ await user.click(within(sheet).getByRole('button', { name: 'Done' }))
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
+
+ // preset active before the flip — the sheet's aria-hidden overlay is gone now
+ await waitFor(() => {
+ expect(within(screen.getByRole('button', { name: 'Table options' })).getByText('1')).toBeInTheDocument()
+ })
+
+ await user.click(screen.getByRole('button', { name: 'Toggle table view' }))
+ await waitFor(() => expect(document.querySelector('table')).not.toBeNull())
+ await user.click(screen.getByRole('button', { name: 'Toggle table view' }))
+ await waitFor(() => expect(screen.getByTestId('cipp-mobile-card-list')).toBeInTheDocument())
+
+ // badge count survived both remounts
+ expect(within(screen.getByRole('button', { name: 'Table options' })).getByText('1')).toBeInTheDocument()
+
+ // the preset chip is marked active too
+ await user.click(screen.getByRole('button', { name: 'Table options' }))
+ const reopened = await screen.findByRole('dialog')
+ const chip = within(reopened).getByText('IT department').closest('.MuiChip-root')
+ expect(chip.className).toContain('MuiChip-filled')
+ }, 15000)
+
+ it('a manual field-visibility change survives a flip, even with preferred columns saved for the page', async () => {
+ narrowState.narrow = true
+ const user = userEvent.setup()
+ // router mock resolves pageName to '' in tests, matching CIPPTableToptoolbar.test.jsx's convention
+ const allColumnsVisible = Object.fromEntries(columns.map((c) => [c, true]))
+ renderCards({ columnDefaults: { '': allColumnsVisible } })
+ await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument())
+ expect(screen.getByText('Department')).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'Table options' }))
+ await screen.findByText('Fields shown')
+ await user.click(screen.getAllByText('Department').at(-1))
+ await user.click(screen.getByRole('button', { name: 'Done' }))
+ await waitFor(() => expect(screen.queryByText('Department')).not.toBeInTheDocument())
+
+ await user.click(screen.getByRole('button', { name: 'Toggle table view' }))
+ await waitFor(() => expect(document.querySelector('table')).not.toBeNull())
+ await user.click(screen.getByRole('button', { name: 'Toggle table view' }))
+ await waitFor(() => expect(screen.getByTestId('cipp-mobile-card-list')).toBeInTheDocument())
+
+ // the manual hide must not be reverted by the saved preferred-columns set on remount
+ expect(screen.queryByText('Department')).not.toBeInTheDocument()
+ }, 15000)
+})
+
+describe('CippMobileCardList table-view page actions FAB', () => {
+ afterEach(() => {
+ narrowState.narrow = false
+ })
+
+ it('narrow viewport moves cardButton into the actions FAB once toggled to table view', async () => {
+ narrowState.narrow = true
+ const user = userEvent.setup()
+ renderCards({}, { cardButton: Add user })
+ await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument())
+
+ await user.click(screen.getByRole('button', { name: 'Toggle table view' }))
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Page actions' })).toBeInTheDocument())
+
+ // action content stays behind the FAB until opened
+ expect(screen.queryByRole('button', { name: 'Add user' })).not.toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'Page actions' }))
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Add user' })).toBeInTheDocument())
+ })
+
+ it('desktop viewport keeps cardButton in the header, no FAB', async () => {
+ const user = userEvent.setup()
+ renderCards({}, { cardButton: Add user })
+ await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument())
+
+ await user.click(screen.getByRole('button', { name: 'Toggle table view' }))
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Add user' })).toBeInTheDocument())
+ expect(screen.queryByRole('button', { name: 'Page actions' })).not.toBeInTheDocument()
+ })
+})
+
+// The Card header hosts a portal target for the toolbar's bulk-actions UI on narrow
+// viewports (CIPPTableToptoolbar's bulkActionsSlot). Row selection itself can't be driven
+// here: CippDataTable's table renders with enableRowVirtualization + enableColumnVirtualization
+// always on, and jsdom never reports a nonzero container size, so react-virtual computes an
+// empty range — thead and tbody both mount with zero cells (verified: no checkboxes, no
+// columnheaders, table-view page actions FAB tests above only ever check for the
+// element itself, never header/row content). Selecting a row to exercise the portal is
+// covered in the CippMobileCardList.stories.jsx browser story instead.
+describe('CippMobileCardList table-view header mounts as the bulk-actions portal target', () => {
+ afterEach(() => {
+ narrowState.narrow = false
+ })
+
+ it('narrow + hideTitle + cardButton: header still mounts even though the FAB owns cardButton', async () => {
+ narrowState.narrow = true
+ const user = userEvent.setup()
+ const { container } = renderCards(
+ {},
+ { hideTitle: true, cardButton: Add user }
+ )
+ await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument())
+
+ await user.click(screen.getByRole('button', { name: 'Toggle table view' }))
+ await waitFor(() => expect(container.querySelector('table')).not.toBeNull())
+
+ // headerAction is undefined here (FAB owns cardButton), so the gate has to key off
+ // cardButton directly or this mounts nothing and the portal target never exists
+ expect(container.querySelector('.MuiCardHeader-root')).not.toBeNull()
+ })
+
+ it('desktop + hideTitle + cardButton: header mounts with cardButton in it, same as before', async () => {
+ const user = userEvent.setup()
+ const { container } = renderCards(
+ {},
+ { hideTitle: true, cardButton: Add user }
+ )
+ await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument())
+
+ await user.click(screen.getByRole('button', { name: 'Toggle table view' }))
+ await waitFor(() => expect(container.querySelector('table')).not.toBeNull())
+
+ const header = container.querySelector('.MuiCardHeader-root')
+ expect(header).not.toBeNull()
+ expect(within(header).getByRole('button', { name: 'Add user' })).toBeInTheDocument()
+ })
+})
+
+describe('CippMobileCardList data source controls', () => {
+ afterEach(() => {
+ narrowState.narrow = false
+ })
+
+ it('renders in the Table options sheet, not in the page actions FAB', async () => {
+ narrowState.narrow = true
+ const user = userEvent.setup()
+ renderCards(
+ {},
+ { dataSourceControls: Live badge , cardButton: Add user }
+ )
+ await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument())
+
+ await user.click(screen.getByRole('button', { name: 'Table options' }))
+ const filterSheet = await within(document.body).findByRole('dialog')
+ expect(within(filterSheet).getByText('Data source')).toBeInTheDocument()
+ expect(within(filterSheet).getByText('Live badge')).toBeInTheDocument()
+
+ await user.click(within(filterSheet).getByRole('button', { name: 'Done' }))
+ await waitFor(() => expect(within(document.body).queryByRole('dialog')).not.toBeInTheDocument())
+
+ // narrow + table view: cardButton lives behind the FAB, dataSourceControls must not follow it there
+ await user.click(screen.getByRole('button', { name: 'Toggle table view' }))
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Page actions' })).toBeInTheDocument())
+
+ // and the table's card header must not double-render them (sheet is the only narrow home)
+ expect(screen.queryByText('Live badge')).not.toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'Page actions' }))
+ const fabSheet = await within(document.body).findByRole('dialog')
+ await waitFor(() => expect(within(fabSheet).getByRole('button', { name: 'Add user' })).toBeInTheDocument())
+ expect(within(fabSheet).queryByText('Live badge')).not.toBeInTheDocument()
+ })
+})
diff --git a/tests/components/CippTable/CippQueueTracker.stories.jsx b/tests/components/CippTable/CippQueueTracker.stories.jsx
index 2c6ecf62b631..077059ccad5d 100644
--- a/tests/components/CippTable/CippQueueTracker.stories.jsx
+++ b/tests/components/CippTable/CippQueueTracker.stories.jsx
@@ -1,193 +1,87 @@
-import { fn, within, expect, userEvent, waitFor } from 'storybook/test'
+import React from 'react'
import { http, HttpResponse } from 'msw'
+import { within, userEvent, waitFor, expect } from 'storybook/test'
import { CippQueueTracker } from '../../../src/components/CippTable/CippQueueTracker'
+import { shrinkToPhoneViewport } from '../../viewport'
-const queueResponses = {
- 'test-queue-running': {
- PartitionKey: 'CippQueue',
- RowKey: 'test-queue-running',
- Name: 'Processing Users',
- Status: 'Running',
- TotalTasks: 10,
- CompletedTasks: 6,
- RunningTasks: 1,
- FailedTasks: 0,
- PercentComplete: 60.0,
- PercentFailed: 0,
- PercentRunning: 10.0,
- Timestamp: '2026-04-08T10:00:00Z',
- Tasks: [
- { Name: 'Process user 1', Status: 'Completed', Timestamp: '2026-04-08T10:00:01Z' },
- { Name: 'Process user 2', Status: 'Completed', Timestamp: '2026-04-08T10:00:02Z' },
- { Name: 'Process user 3', Status: 'Completed', Timestamp: '2026-04-08T10:00:03Z' },
- { Name: 'Process user 4', Status: 'Completed', Timestamp: '2026-04-08T10:00:04Z' },
- { Name: 'Process user 5', Status: 'Completed', Timestamp: '2026-04-08T10:00:05Z' },
- { Name: 'Process user 6', Status: 'Completed', Timestamp: '2026-04-08T10:00:06Z' },
- { Name: 'Process user 7', Status: 'Running', Timestamp: '2026-04-08T10:00:07Z' },
- { Name: 'Process user 8', Status: 'Pending', Timestamp: '2026-04-08T10:00:08Z' },
- { Name: 'Process user 9', Status: 'Pending', Timestamp: '2026-04-08T10:00:09Z' },
- { Name: 'Process user 10', Status: 'Pending', Timestamp: '2026-04-08T10:00:10Z' },
- ],
- },
- 'test-queue-done': {
- PartitionKey: 'CippQueue',
- RowKey: 'test-queue-done',
- Name: 'User Processing',
- Status: 'Completed',
- TotalTasks: 5,
- CompletedTasks: 5,
- RunningTasks: 0,
- FailedTasks: 0,
- PercentComplete: 100.0,
- PercentFailed: 0,
- PercentRunning: 0,
- Timestamp: '2026-04-08T10:00:00Z',
- Tasks: [
- { Name: 'Process user 1', Status: 'Completed', Timestamp: '2026-04-08T10:00:01Z' },
- { Name: 'Process user 2', Status: 'Completed', Timestamp: '2026-04-08T10:00:02Z' },
- { Name: 'Process user 3', Status: 'Completed', Timestamp: '2026-04-08T10:00:03Z' },
- { Name: 'Process user 4', Status: 'Completed', Timestamp: '2026-04-08T10:00:04Z' },
- { Name: 'Process user 5', Status: 'Completed', Timestamp: '2026-04-08T10:00:05Z' },
- ],
- },
- 'test-queue-failed': {
- PartitionKey: 'CippQueue',
- RowKey: 'test-queue-failed',
- Name: 'Failed Operation',
- Status: 'Failed',
- TotalTasks: 5,
- CompletedTasks: 2,
- RunningTasks: 0,
- FailedTasks: 1,
- PercentComplete: 40.0,
- PercentFailed: 20.0,
- PercentRunning: 0,
- Timestamp: '2026-04-08T10:00:00Z',
- Tasks: [
- { Name: 'Process user 1', Status: 'Completed', Timestamp: '2026-04-08T10:00:01Z' },
- { Name: 'Process user 2', Status: 'Completed', Timestamp: '2026-04-08T10:00:02Z' },
- { Name: 'Process user 3', Status: 'Failed', Timestamp: '2026-04-08T10:00:03Z' },
- { Name: 'Process user 4', Status: 'Pending', Timestamp: '2026-04-08T10:00:04Z' },
- { Name: 'Process user 5', Status: 'Pending', Timestamp: '2026-04-08T10:00:05Z' },
- ],
- },
+// The task names are tenant default domains — one unbreakable token each, and the test
+// tenants are the longest of them.
+const queue = {
+ QueueId: 'q-1',
+ Name: 'Users (All Tenants)',
+ Status: 'Running',
+ PercentComplete: 20.3,
+ TotalTasks: 133,
+ CompletedTasks: 27,
+ RunningTasks: 4,
+ FailedTasks: 0,
+ Tasks: [
+ {
+ Name: 'cyberdraintesttenant024.onmicrosoft.com',
+ Status: 'Completed',
+ Timestamp: '2026-08-12T23:15:33Z',
+ },
+ {
+ Name: 'cyberdraintesttenant023.onmicrosoft.com',
+ Status: 'Running',
+ Timestamp: '2026-08-12T23:15:31Z',
+ },
+ {
+ Name: 'cyberdraintesttenant022.onmicrosoft.com',
+ Status: 'Completed',
+ Timestamp: '2026-08-12T23:15:34Z',
+ },
+ ],
}
-// Single handler that returns different data based on QueueId query param.
-// Matches actual Invoke-ListCippQueue response shape.
-const queueHandler = http.get('/api/ListCippQueue', ({ request }) => {
- const url = new URL(request.url)
- const queueId = url.searchParams.get('QueueId')
- const data = queueResponses[queueId]
- if (data) {
- return HttpResponse.json([data])
- }
- return HttpResponse.json([])
-})
+const handlers = [http.get('*/api/ListCippQueue', () => HttpResponse.json([queue]))]
export default {
title: 'Components/CippTable/CippQueueTracker',
component: CippQueueTracker,
tags: ['autodocs'],
- args: {
- onQueueComplete: fn(),
- },
- beforeEach({ msw }) {
- msw.use(queueHandler)
- },
+ parameters: { msw: { handlers } },
}
-// Idle: no queueId, component renders nothing (returns null).
-export const Idle = {
- args: {
- queueId: null,
- queryKey: 'storybook-idle',
- title: 'Queue Tracker',
- },
+export const PhoneWidth = {
+ render: () => ,
play: async ({ canvasElement, step }) => {
- await step('no queueId renders nothing', async () => {
- // Component returns null when no queueId, canvas should be empty
- await new Promise((r) => setTimeout(r, 500))
- expect(canvasElement.querySelector('button')).toBeNull()
- })
- },
-}
+ const onAPhone = await shrinkToPhoneViewport()
+ const canvas = within(canvasElement)
+ const body = within(document.body)
-export const InProgress = {
- args: {
- queueId: 'test-queue-running',
- queryKey: 'storybook-running',
- title: 'Processing Users',
- },
- play: async ({ canvasElement, step }) => {
- const root = within(document.body)
-
- await step('tracker button appears once queue data loads', async () => {
- await waitFor(() => {
- expect(canvasElement.querySelector('button')).not.toBeNull()
- })
- await userEvent.click(canvasElement.querySelector('button'))
+ await step('the tracker opens the queue offcanvas', async () => {
+ const trigger = await canvas.findByRole('button')
+ await userEvent.click(trigger)
+ await waitFor(() => expect(body.getByText('Task Details')).toBeInTheDocument())
})
- await step('offcanvas shows running progress and the active task', async () => {
- await waitFor(() => {
- expect(root.getByText('Processing Users')).toBeVisible()
- })
- expect(root.getByText(/60\.0%/)).toBeVisible()
- expect(root.getByText('Process user 7')).toBeVisible()
- })
- },
-}
+ if (!onAPhone) return
-export const Completed = {
- args: {
- queueId: 'test-queue-done',
- queryKey: 'storybook-done',
- title: 'User Processing',
- },
- play: async ({ canvasElement, args, step }) => {
- const root = within(document.body)
-
- await step('open the tracker offcanvas', async () => {
- await waitFor(() => {
- expect(canvasElement.querySelector('button')).not.toBeNull()
- })
- await userEvent.click(canvasElement.querySelector('button'))
- })
-
- await step('shows 100% and fires onQueueComplete', async () => {
- await waitFor(() => {
- expect(root.getByText('User Processing')).toBeVisible()
- })
- expect(root.getByText(/100\.0%/)).toBeVisible()
- await waitFor(() => {
- expect(args.onQueueComplete).toHaveBeenCalled()
- })
- })
- },
-}
+ // scope to one task card — statuses repeat across cards and in the stats row
+ const card = (name) => within(body.getByText(name).closest('.MuiBox-root'))
-export const Failed = {
- args: {
- queueId: 'test-queue-failed',
- queryKey: 'storybook-failed',
- title: 'Failed Operation',
- },
- play: async ({ canvasElement, step }) => {
- const root = within(document.body)
-
- await step('open the tracker offcanvas', async () => {
+ await step('a full tenant domain does not push its status pill off the card', async () => {
+ const paper = body.getByText('Task Details').closest('.MuiDrawer-paper')
+ const pill = card('cyberdraintesttenant024.onmicrosoft.com').getByText(/^completed$/i)
await waitFor(() => {
- expect(canvasElement.querySelector('button')).not.toBeNull()
+ // the pill is intact inside the drawer, not clipped at its right edge
+ expect(pill.getBoundingClientRect().right).toBeLessThanOrEqual(
+ paper.getBoundingClientRect().right
+ )
+ expect(paper.scrollWidth).toBeLessThanOrEqual(paper.clientWidth)
})
- await userEvent.click(canvasElement.querySelector('button'))
})
- await step('shows the failed operation and its failed task', async () => {
- await waitFor(() => {
- expect(root.getByText('Failed Operation')).toBeVisible()
- })
- expect(root.getByText('Process user 3')).toBeVisible()
+ await step('and there is real space between the name and the pill', async () => {
+ const name = body.getByText('cyberdraintesttenant023.onmicrosoft.com')
+ const running = card('cyberdraintesttenant023.onmicrosoft.com').getByText(/^running$/i)
+ const nameBox = name.getBoundingClientRect()
+ const pillBox = running.getBoundingClientRect()
+ // either beside it with a gap, or wrapped below it — never overlapping
+ const besideWithGap = pillBox.left - nameBox.right >= 4
+ const below = pillBox.top >= nameBox.bottom - 1
+ await expect(besideWithGap || below).toBe(true)
})
},
}
diff --git a/tests/components/CippTable/order-columns-by-selection.test.js b/tests/components/CippTable/order-columns-by-selection.test.js
new file mode 100644
index 000000000000..c5618a0c8cf0
--- /dev/null
+++ b/tests/components/CippTable/order-columns-by-selection.test.js
@@ -0,0 +1,36 @@
+import { describe, it, expect } from 'vitest'
+import { orderColumnsBySelection } from '../../../src/components/CippTable/CippDataTable'
+
+// MRT reads initialState.columnOrder once; when the graph filter swaps the $select list
+// after mount, new columns appended last — and the card view fills its three detail slots
+// in column order, so the field the user just selected was the one overflowing into
+// "+N more". Selection order has to win.
+describe('orderColumnsBySelection', () => {
+ const all = ['displayName', 'userPrincipalName', 'mail', 'signInActivity.lastSuccessfulSignInDateTime', 'proxyAddresses']
+
+ it('puts the selection first, in selection order', () => {
+ expect(
+ orderColumnsBySelection(all, ['signInActivity.lastSuccessfulSignInDateTime', 'displayName'])
+ ).toEqual([
+ 'signInActivity.lastSuccessfulSignInDateTime',
+ 'displayName',
+ 'userPrincipalName',
+ 'mail',
+ 'proxyAddresses',
+ ])
+ })
+
+ it('ignores selected ids that have no column, keeps the rest stable', () => {
+ expect(orderColumnsBySelection(all, ['nope', 'mail'])).toEqual([
+ 'mail',
+ 'displayName',
+ 'userPrincipalName',
+ 'signInActivity.lastSuccessfulSignInDateTime',
+ 'proxyAddresses',
+ ])
+ })
+
+ it('is a no-op shape when nothing is selected', () => {
+ expect(orderColumnsBySelection(all, [])).toEqual(all)
+ })
+})
diff --git a/tests/components/CippTable/util-columnsFromAPI.test.jsx b/tests/components/CippTable/util-columnsFromAPI.test.jsx
index 3840f4bd7b58..9023485aeb4f 100644
--- a/tests/components/CippTable/util-columnsFromAPI.test.jsx
+++ b/tests/components/CippTable/util-columnsFromAPI.test.jsx
@@ -13,6 +13,22 @@ describe('utilColumnsFromAPI', () => {
expect(ids).toContain('department')
})
+ it('includes assigned license filter options found after the heuristic sample', () => {
+ const businessPremiumSku = 'cbdc14ab-d96c-4c30-b9f4-6ada7cdc1d46'
+ const data = Array.from({ length: 51 }, (_, index) => ({
+ assignedLicenses: index === 50 ? [{ skuId: businessPremiumSku }] : [],
+ }))
+
+ const licenseColumn = utilColumnsFromAPI(data).find(
+ (column) => column.id === 'assignedLicenses'
+ )
+
+ expect(licenseColumn.filterSelectOptions).toContainEqual({
+ label: 'Microsoft 365 Business Premium',
+ value: businessPremiumSku,
+ })
+ })
+
it('generates columns for nested object properties', () => {
const data = [
{ info: { city: 'Seattle', state: 'WA' }, name: 'Test' },
diff --git a/tests/components/CippTable/util-mobile-card-slots.test.js b/tests/components/CippTable/util-mobile-card-slots.test.js
new file mode 100644
index 000000000000..0a6ff5416cfa
--- /dev/null
+++ b/tests/components/CippTable/util-mobile-card-slots.test.js
@@ -0,0 +1,146 @@
+import { getMobileCardSlots, isStatusLike } from '../../../src/components/CippTable/util-mobile-card-slots'
+
+// Shorthand column factory mirroring what table.getVisibleLeafColumns() yields
+const col = (id, def = {}) => ({ id, columnDef: { id, ...def } })
+const bool = (id) => col(id, { sortingFn: 'boolean', filterVariant: 'select', filterSelectOptions: ['Yes', 'No'] })
+
+const ids = (cols) => cols.map((c) => c.id)
+
+// The real /identity/administration/users simpleColumns, in page order.
+// accountEnabled mirrors its explicit get-cipp-filter-variant case: select variant,
+// alphanumeric sorting, NO options — only the STATUS_FIELDS id match can catch it.
+const USERS_COLUMNS = [
+ col('accountEnabled', { filterVariant: 'select', sortingFn: 'alphanumeric', filterFn: 'equals' }),
+ col('userPrincipalName'),
+ col('displayName'),
+ col('mail'),
+ col('businessPhones'),
+ col('proxyAddresses'),
+ col('assignedLicenses'),
+ col('licenseAssignmentStates'),
+ col('userType', { filterVariant: 'select', filterSelectOptions: ['Member', 'Guest'] }),
+]
+
+describe('getMobileCardSlots', () => {
+ it('resolves the users page correctly — never titles cards "Yes"', () => {
+ const slots = getMobileCardSlots(USERS_COLUMNS)
+ expect(slots.primary.id).toBe('displayName')
+ expect(slots.secondary.id).toBe('userPrincipalName')
+ expect(ids(slots.chips)).toEqual(['accountEnabled', 'userType'])
+ expect(ids(slots.details)).toEqual(['mail', 'businessPhones', 'proxyAddresses'])
+ expect(ids(slots.rest)).toEqual(['assignedLicenses', 'licenseAssignmentStates'])
+ expect(slots.restCount).toBe(2)
+ })
+
+ it('filters out mrt-* utility columns', () => {
+ const slots = getMobileCardSlots([col('mrt-row-select'), col('displayName'), col('mrt-row-actions')])
+ expect(slots.primary.id).toBe('displayName')
+ expect(slots.secondary).toBeNull()
+ expect(slots.restCount).toBe(0)
+ })
+
+ it('handles an empty column set', () => {
+ expect(getMobileCardSlots([])).toEqual({
+ primary: null,
+ secondary: null,
+ chips: [],
+ details: [],
+ rest: [],
+ restCount: 0,
+ })
+ expect(getMobileCardSlots(undefined).primary).toBeNull()
+ })
+
+ it('handles a single column', () => {
+ const slots = getMobileCardSlots([col('Tenant')])
+ expect(slots.primary.id).toBe('Tenant')
+ expect(slots.secondary).toBeNull()
+ expect(slots.chips).toEqual([])
+ expect(slots.details).toEqual([])
+ })
+
+ it('falls back to first non-status textual column when nothing matches NAME_FIELDS', () => {
+ const slots = getMobileCardSlots([bool('isCompliant'), col('osVersion'), col('manufacturer')])
+ expect(slots.primary.id).toBe('osVersion')
+ expect(ids(slots.chips)).toEqual(['isCompliant'])
+ expect(ids(slots.details)).toEqual(['manufacturer'])
+ })
+
+ it('falls back to the first column when everything is status-like', () => {
+ const slots = getMobileCardSlots([bool('enabled'), bool('isCompliant')])
+ expect(slots.primary.id).toBe('enabled')
+ expect(ids(slots.chips)).toEqual(['isCompliant'])
+ })
+
+ it('caps chips at 3 and details at 3, remainder goes to rest', () => {
+ const slots = getMobileCardSlots([
+ col('displayName'),
+ bool('a'), bool('b'), bool('c'), bool('d'),
+ col('e'), col('f'), col('g'), col('h'),
+ ])
+ expect(ids(slots.chips)).toEqual(['a', 'b', 'c'])
+ // 'd' overflowed the chip cap — it flows into details ("whatever remains"), not rest
+ expect(ids(slots.details)).toEqual(['d', 'e', 'f'])
+ expect(ids(slots.rest)).toEqual(['g', 'h'])
+ })
+
+ it('respects mobileCard overrides for every slot', () => {
+ const slots = getMobileCardSlots(USERS_COLUMNS, {
+ primary: 'userPrincipalName',
+ secondary: 'mail',
+ chips: ['userType'],
+ details: ['assignedLicenses'],
+ })
+ expect(slots.primary.id).toBe('userPrincipalName')
+ expect(slots.secondary.id).toBe('mail')
+ expect(ids(slots.chips)).toEqual(['userType'])
+ expect(ids(slots.details)).toEqual(['assignedLicenses'])
+ // everything unassigned lands in rest
+ expect(ids(slots.rest)).toEqual([
+ 'accountEnabled',
+ 'displayName',
+ 'businessPhones',
+ 'proxyAddresses',
+ 'licenseAssignmentStates',
+ ])
+ })
+
+ it('ignores override ids that are not visible and empty override arrays fall through to rest', () => {
+ const slots = getMobileCardSlots(USERS_COLUMNS, { primary: 'notAColumn', chips: [], details: [] })
+ expect(slots.primary.id).toBe('displayName') // heuristic fallback
+ expect(slots.chips).toEqual([])
+ expect(slots.details).toEqual([])
+ expect(slots.restCount).toBe(7)
+ })
+
+ it('secondary never duplicates primary', () => {
+ const slots = getMobileCardSlots([col('RowKey'), col('Timestamp')])
+ // RowKey matches both NAME_FIELDS and IDENTIFIER_FIELDS — must not appear twice
+ expect(slots.primary.id).toBe('RowKey')
+ expect(slots.secondary).toBeNull()
+ expect(ids(slots.details)).toEqual(['Timestamp'])
+ })
+})
+
+describe('isStatusLike', () => {
+ it('detects boolean sortingFn (the get-cipp-filter-variant signal)', () => {
+ expect(isStatusLike(bool('anything'))).toBe(true)
+ })
+ it('detects known status ids case-insensitively', () => {
+ expect(isStatusLike(col('complianceState'))).toBe(true)
+ expect(isStatusLike(col('Severity'))).toBe(true)
+ // the identity/device/custom test tables — Risk fell to the detail rows while Result sat
+ // in the chips row, two chips organised by two different systems on one card
+ expect(isStatusLike(col('Risk'))).toBe(true)
+ expect(isStatusLike(col('Result'))).toBe(true)
+ })
+ it('detects small select filters, rejects large ones', () => {
+ expect(isStatusLike(col('x', { filterVariant: 'select', filterSelectOptions: ['a', 'b'] }))).toBe(true)
+ expect(
+ isStatusLike(col('x', { filterVariant: 'select', filterSelectOptions: ['a', 'b', 'c', 'd', 'e', 'f', 'g'] }))
+ ).toBe(false)
+ })
+ it('rejects plain text columns', () => {
+ expect(isStatusLike(col('displayName'))).toBe(false)
+ })
+})
diff --git a/tests/components/CippTable/util-subTables.test.js b/tests/components/CippTable/util-subTables.test.js
new file mode 100644
index 000000000000..bb3ee02a32a2
--- /dev/null
+++ b/tests/components/CippTable/util-subTables.test.js
@@ -0,0 +1,62 @@
+import {
+ dataHasPopulatedColumn,
+ resolveSubTableSimpleColumns,
+ subTableIsSelected,
+ subTableShowsCachedColumn,
+ getSubTableDisplayColumnIds,
+ columnOrderHasStaleIds,
+} from '../../../src/components/CippTable/util-subTables'
+
+const membersSub = {
+ id: 'members',
+ header: 'Members',
+ cachedColumn: 'membersCsv',
+}
+
+describe('util-subTables', () => {
+ it('selects a subTable only when its id is in simpleColumns', () => {
+ expect(subTableIsSelected(membersSub, ['displayName', 'members'])).toBe(true)
+ expect(subTableIsSelected(membersSub, ['displayName'])).toBe(false)
+ expect(subTableIsSelected(membersSub, [])).toBe(true)
+ })
+
+ it('uses the cached column when that field is present on the data', () => {
+ const cached = [{ id: '1', membersCsv: 'Jane, Bob' }]
+ const live = [{ id: '1', displayName: 'Finance' }]
+ const liveWithEmptyCsv = [{ id: '1', displayName: 'Finance', membersCsv: '' }]
+
+ expect(dataHasPopulatedColumn(cached, 'membersCsv')).toBe(true)
+ expect(dataHasPopulatedColumn(liveWithEmptyCsv, 'membersCsv')).toBe(false)
+ expect(subTableShowsCachedColumn(membersSub, cached)).toBe(true)
+ expect(subTableShowsCachedColumn(membersSub, live)).toBe(false)
+ expect(subTableShowsCachedColumn(membersSub, liveWithEmptyCsv)).toBe(false)
+ expect(
+ resolveSubTableSimpleColumns(['displayName', 'members'], [membersSub], cached)
+ ).toEqual(['displayName', 'membersCsv'])
+ expect(
+ resolveSubTableSimpleColumns(['displayName', 'members'], [membersSub], live)
+ ).toEqual(['displayName', 'members'])
+ })
+
+ it('maps subTables to the active display column ids', () => {
+ const cached = [{ id: '1', membersCsv: 'Jane, Bob' }]
+ const live = [{ id: '1', displayName: 'Finance' }]
+
+ expect(
+ getSubTableDisplayColumnIds([membersSub], ['displayName', 'members'], cached)
+ ).toEqual(['membersCsv'])
+ expect(
+ getSubTableDisplayColumnIds([membersSub], ['displayName', 'members'], live)
+ ).toEqual(['members'])
+ })
+
+ it('detects stale column order ids that are not on the table', () => {
+ expect(columnOrderHasStaleIds(['displayName', 'members'], ['displayName', 'membersCsv'])).toBe(
+ true
+ )
+ expect(
+ columnOrderHasStaleIds(['displayName', 'membersCsv'], ['displayName', 'membersCsv'])
+ ).toBe(false)
+ expect(columnOrderHasStaleIds(['mrt-row-select', 'displayName'], ['displayName'])).toBe(false)
+ })
+})
diff --git a/tests/components/CippTable/util-tablemode.test.jsx b/tests/components/CippTable/util-tablemode.test.jsx
index 8497f9d0dde4..790704246c9d 100644
--- a/tests/components/CippTable/util-tablemode.test.jsx
+++ b/tests/components/CippTable/util-tablemode.test.jsx
@@ -42,6 +42,26 @@ describe('utilTableMode', () => {
expect(result.muiPaginationProps.rowsPerPageOptions).toBeDefined()
})
+ it('narrow table slims the footer so it cannot wrap below MRT 720px pivot', () => {
+ const result = utilTableMode({}, false, null, [], false, null, '380px', defaultSettings, 'table', true)
+ expect(result.muiPaginationProps.showRowsPerPage).toBe(false)
+ expect(result.muiPaginationProps.showFirstButton).toBe(false)
+ expect(result.muiPaginationProps.showLastButton).toBe(false)
+ })
+
+ it('narrow table page-scrolls instead of keeping an inner scroll viewport', () => {
+ const result = utilTableMode({}, false, null, [], false, null, '380px', defaultSettings, 'table', true)
+ expect(result.muiTableContainerProps.sx.maxHeight).toBe('none')
+ })
+
+ it('wide table keeps the full footer and the viewport-budget maxHeight', () => {
+ const result = utilTableMode({}, false, null, [], false, null, '380px', defaultSettings, 'table', false)
+ expect(result.muiPaginationProps.showRowsPerPage).toBeUndefined()
+ expect(result.muiPaginationProps.showFirstButton).toBeUndefined()
+ expect(result.muiPaginationProps.showLastButton).toBeUndefined()
+ expect(result.muiTableContainerProps.sx.maxHeight).toBe('calc(100vh - 380px)')
+ })
+
it('returns table container height config', () => {
const result = utilTableMode({}, false, null, [], false, null, '500px', defaultSettings)
expect(result.muiTableContainerProps).toBeDefined()
diff --git a/tests/components/CippWizard/CippWizardAutopilotImport.stories.jsx b/tests/components/CippWizard/CippWizardAutopilotImport.stories.jsx
new file mode 100644
index 000000000000..5ad28e86c866
--- /dev/null
+++ b/tests/components/CippWizard/CippWizardAutopilotImport.stories.jsx
@@ -0,0 +1,75 @@
+import React from 'react'
+import { http, HttpResponse } from 'msw'
+import { within, expect, userEvent } from 'storybook/test'
+import { useForm } from 'react-hook-form'
+import { CippWizardAutopilotImport } from '../../../src/components/CippWizard/CippWizardAutopilotImport'
+import { shrinkToPhoneViewport } from '../../viewport'
+
+// The six the real autopilot wizard passes — the count is what made the row overflow.
+const fields = [
+ { friendlyName: 'Serialnumber', propertyName: 'SerialNumber' },
+ { friendlyName: 'Manufacturer', propertyName: 'oemManufacturerName' },
+ { friendlyName: 'Model', propertyName: 'modelName' },
+ { friendlyName: 'Product ID', propertyName: 'productKey' },
+ { friendlyName: 'Hardware hash', propertyName: 'hardwareHash' },
+ { friendlyName: 'Group Tag', propertyName: 'groupTag' },
+]
+
+const handlers = [
+ http.get('*/api/ListGraphRequest', () => HttpResponse.json({ Results: [] })),
+ http.get('*/api/ListGraphExplorerPresets', () => HttpResponse.json({ Results: [] })),
+]
+
+const Harness = () => {
+ const formControl = useForm({ mode: 'onChange', defaultValues: { autopilotData: [] } })
+ return (
+ {}}
+ onPreviousStep={() => {}}
+ />
+ )
+}
+
+export default {
+ title: 'Components/CippWizard/CippWizardAutopilotImport',
+ component: CippWizardAutopilotImport,
+ parameters: { msw: { handlers } },
+}
+
+// A 32px badge, six 150px fields and a 48px delete came to ~1010px in a row whose only
+// concession was overflowX:auto — a nested sideways scroller inside a full-screen dialog.
+// Whether it fits now is a claim only a real browser can settle.
+export const PhoneWidth = {
+ render: () => ,
+ play: async ({ canvasElement }) => {
+ const onAPhone = await shrinkToPhoneViewport()
+ const body = within(document.body)
+
+ // At phone width the table is a card list, so the import buttons are behind the FAB
+ // rather than in a card header — the same route a user takes.
+ if (onAPhone) {
+ await userEvent.click(await body.findByRole('button', { name: 'Page actions' }))
+ }
+ await userEvent.click(await body.findByRole('button', { name: /manual import/i }))
+ const dialog = await body.findByRole('dialog')
+ if (!onAPhone) return
+
+ const rows = dialog.querySelectorAll('[data-testid="manual-row"]')
+ expect(rows.length).toBeGreaterThan(0)
+ rows.forEach((row) => {
+ expect(row.scrollWidth).toBeLessThanOrEqual(row.clientWidth)
+ })
+
+ // and the fields are stacked, not side by side
+ const inputs = rows[0].querySelectorAll('input')
+ expect(inputs.length).toBe(fields.length)
+ expect(inputs[1].getBoundingClientRect().top).toBeGreaterThan(
+ inputs[0].getBoundingClientRect().bottom
+ )
+ },
+}
diff --git a/tests/components/CippWizard/CippWizardAutopilotImport.test.jsx b/tests/components/CippWizard/CippWizardAutopilotImport.test.jsx
new file mode 100644
index 000000000000..3720e883f933
--- /dev/null
+++ b/tests/components/CippWizard/CippWizardAutopilotImport.test.jsx
@@ -0,0 +1,85 @@
+import React from 'react'
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { screen, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { useForm } from 'react-hook-form'
+import { renderWithProviders } from '../../test-utils'
+import { CippWizardAutopilotImport } from '../../../src/components/CippWizard/CippWizardAutopilotImport'
+
+// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook
+const layoutState = vi.hoisted(() => ({ isMobile: false }))
+// partial mock: real module spread first, so new exports keep working here
+vi.mock('../../../src/hooks/use-breakpoint', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useIsMobileLayout: () => layoutState.isMobile,
+ useIsTabletLayout: () => false,
+ useTableViewMode: () => 'table',
+}))
+
+vi.mock('../../../src/api/ApiCall', () => ({
+ ApiGetCall: vi.fn(() => ({ data: undefined, isFetching: false, isSuccess: false })),
+ ApiPostCall: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
+ ApiGetCallWithPagination: vi.fn(() => ({ data: undefined, isFetching: false })),
+}))
+
+// The six the real autopilot wizard passes — the count is the point.
+const fields = [
+ { friendlyName: 'Serialnumber', propertyName: 'SerialNumber' },
+ { friendlyName: 'Manufacturer', propertyName: 'oemManufacturerName' },
+ { friendlyName: 'Model', propertyName: 'modelName' },
+ { friendlyName: 'Product ID', propertyName: 'productKey' },
+ { friendlyName: 'Hardware hash', propertyName: 'hardwareHash' },
+ { friendlyName: 'Group Tag', propertyName: 'groupTag' },
+]
+
+const Harness = () => {
+ const formControl = useForm({ mode: 'onChange', defaultValues: { autopilotData: [] } })
+ return (
+ {}}
+ onPreviousStep={() => {}}
+ />
+ )
+}
+
+const openManualImport = async () => {
+ const user = userEvent.setup()
+ renderWithProviders( )
+ await user.click(await screen.findByRole('button', { name: /manual import/i }))
+ return within(await screen.findByRole('dialog'))
+}
+
+beforeEach(() => {
+ layoutState.isMobile = false
+})
+
+describe('CippWizardAutopilotImport manual entry', () => {
+ // Six 150px fields plus an index badge and a delete button come to ~1010px, which on a
+ // phone was reachable only by scrolling a nested container inside a full-screen dialog.
+ it('gives each device its own card on a phone', async () => {
+ layoutState.isMobile = true
+ const dialog = await openManualImport()
+
+ expect(dialog.getByText('Device 1')).toBeInTheDocument()
+ expect(dialog.getByRole('button', { name: 'Remove device 1' })).toBeInTheDocument()
+ // every field still there, just stacked
+ fields.forEach((field) => {
+ expect(dialog.getByLabelText(field.friendlyName)).toBeInTheDocument()
+ })
+ })
+
+ it('keeps the single scrolling row on desktop', async () => {
+ const dialog = await openManualImport()
+
+ expect(dialog.queryByText('Device 1')).not.toBeInTheDocument()
+ expect(dialog.queryByRole('button', { name: 'Remove device 1' })).not.toBeInTheDocument()
+ fields.forEach((field) => {
+ expect(dialog.getByLabelText(field.friendlyName)).toBeInTheDocument()
+ })
+ })
+})
diff --git a/tests/components/CippWizard/CippWizardAutopilotTypeSelection.stories.jsx b/tests/components/CippWizard/CippWizardAutopilotTypeSelection.stories.jsx
new file mode 100644
index 000000000000..ff5d2dfdb6f1
--- /dev/null
+++ b/tests/components/CippWizard/CippWizardAutopilotTypeSelection.stories.jsx
@@ -0,0 +1,30 @@
+import React from 'react'
+import { useForm } from 'react-hook-form'
+import { CippWizardAutopilotTypeSelection } from '../../../src/components/CippWizard/CippWizardAutopilotTypeSelection'
+
+// Mirrors the add-device wizard's initialState: autopilot is preselected so the
+// user can click Next without touching the step.
+const Harness = () => {
+ const formControl = useForm({
+ mode: 'onChange',
+ defaultValues: { deploymentType: 'autopilot' },
+ })
+ return (
+ {}}
+ onPreviousStep={() => {}}
+ />
+ )
+}
+
+export default {
+ title: 'Components/CippWizard/CippWizardAutopilotTypeSelection',
+ component: CippWizardAutopilotTypeSelection,
+}
+
+export const Default = {
+ render: () => ,
+}
diff --git a/tests/components/CippWizard/CippWizardAutopilotTypeSelection.test.jsx b/tests/components/CippWizard/CippWizardAutopilotTypeSelection.test.jsx
new file mode 100644
index 000000000000..9a7882c19d5e
--- /dev/null
+++ b/tests/components/CippWizard/CippWizardAutopilotTypeSelection.test.jsx
@@ -0,0 +1,75 @@
+import React from 'react'
+import { describe, it, expect } from 'vitest'
+import { screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { useForm } from 'react-hook-form'
+import { renderWithProviders } from '../../test-utils'
+import { CippWizardAutopilotTypeSelection } from '../../../src/components/CippWizard/CippWizardAutopilotTypeSelection'
+
+const Harness = ({ onForm, defaultValues }) => {
+ const formControl = useForm({
+ mode: 'onChange',
+ // Mirrors the add-device wizard's initialState
+ defaultValues: { deploymentType: 'autopilot', ...defaultValues },
+ })
+ onForm?.(formControl)
+ return (
+ {}}
+ onPreviousStep={() => {}}
+ />
+ )
+}
+
+describe('CippWizardAutopilotTypeSelection', () => {
+ it('preselects autopilot so Next is enabled without a click', async () => {
+ let form
+ renderWithProviders( (form = f)} />)
+
+ expect(form.getValues('deploymentType')).toBe('autopilot')
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /next step/i })).toBeEnabled()
+ })
+ })
+
+ it('switches to device prep and clears the autopilot fields', async () => {
+ const user = userEvent.setup()
+ let form
+ renderWithProviders(
+ (form = f)}
+ defaultValues={{ autopilotData: [{ SerialNumber: 'SN1' }], GroupName: 'group' }}
+ />
+ )
+
+ await user.click(screen.getByText('Device Preparation (Corporate Identifiers)'))
+
+ expect(form.getValues('deploymentType')).toBe('devicePrep')
+ expect(form.getValues('autopilotData')).toBeUndefined()
+ expect(form.getValues('GroupName')).toBeUndefined()
+ })
+
+ it('switches back to autopilot and clears the device prep fields', async () => {
+ const user = userEvent.setup()
+ let form
+ renderWithProviders(
+ (form = f)}
+ defaultValues={{
+ deploymentType: 'devicePrep',
+ devicePrepData: [{ manufacturer: 'Dell', model: 'XPS', serialNumber: 'SN1' }],
+ overwriteExisting: true,
+ }}
+ />
+ )
+
+ await user.click(screen.getByText('Windows Autopilot'))
+
+ expect(form.getValues('deploymentType')).toBe('autopilot')
+ expect(form.getValues('devicePrepData')).toBeUndefined()
+ expect(form.getValues('overwriteExisting')).toBeUndefined()
+ })
+})
diff --git a/tests/components/CippWizard/CippWizardDevicePrepImport.stories.jsx b/tests/components/CippWizard/CippWizardDevicePrepImport.stories.jsx
new file mode 100644
index 000000000000..daf9810b70b6
--- /dev/null
+++ b/tests/components/CippWizard/CippWizardDevicePrepImport.stories.jsx
@@ -0,0 +1,58 @@
+import React from 'react'
+import { http, HttpResponse } from 'msw'
+import { useForm } from 'react-hook-form'
+import { CippWizardDevicePrepImport } from '../../../src/components/CippWizard/CippWizardDevicePrepImport'
+
+// The three the device prep wizard passes — a corporate identifier is exactly this triplet.
+const fields = [
+ { friendlyName: 'Manufacturer', propertyName: 'manufacturer' },
+ { friendlyName: 'Model', propertyName: 'model' },
+ { friendlyName: 'Serial Number', propertyName: 'serialNumber' },
+]
+
+const handlers = [
+ http.get('*/api/ListGraphRequest', () => HttpResponse.json({ Results: [] })),
+ http.get('*/api/ListGraphExplorerPresets', () => HttpResponse.json({ Results: [] })),
+]
+
+const Harness = ({ defaultValues }) => {
+ const formControl = useForm({
+ mode: 'onChange',
+ defaultValues: { devicePrepData: [], ...defaultValues },
+ })
+ return (
+ {}}
+ onPreviousStep={() => {}}
+ />
+ )
+}
+
+export default {
+ title: 'Components/CippWizard/CippWizardDevicePrepImport',
+ component: CippWizardDevicePrepImport,
+ parameters: { msw: { handlers } },
+}
+
+export const Empty = {
+ render: () => ,
+}
+
+export const WithDevices = {
+ render: () => (
+
+ ),
+}
diff --git a/tests/components/CippWizard/CippWizardDevicePrepImport.test.jsx b/tests/components/CippWizard/CippWizardDevicePrepImport.test.jsx
new file mode 100644
index 000000000000..bcb0b857a89e
--- /dev/null
+++ b/tests/components/CippWizard/CippWizardDevicePrepImport.test.jsx
@@ -0,0 +1,124 @@
+import React from 'react'
+import { describe, it, expect, vi } from 'vitest'
+import { screen, within, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { useForm } from 'react-hook-form'
+import { renderWithProviders } from '../../test-utils'
+import { CippWizardDevicePrepImport } from '../../../src/components/CippWizard/CippWizardDevicePrepImport'
+
+vi.mock('../../../src/hooks/use-breakpoint', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useIsMobileLayout: () => false,
+ useIsTabletLayout: () => false,
+ useTableViewMode: () => 'table',
+}))
+
+vi.mock('../../../src/api/ApiCall', () => ({
+ ApiGetCall: vi.fn(() => ({ data: undefined, isFetching: false, isSuccess: false })),
+ ApiPostCall: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
+ ApiGetCallWithPagination: vi.fn(() => ({ data: undefined, isFetching: false })),
+}))
+
+// The three the device prep wizard passes — a corporate identifier is exactly this triplet.
+const fields = [
+ { friendlyName: 'Manufacturer', propertyName: 'manufacturer' },
+ { friendlyName: 'Model', propertyName: 'model' },
+ { friendlyName: 'Serial Number', propertyName: 'serialNumber' },
+]
+
+const Harness = () => {
+ const formControl = useForm({ mode: 'onChange', defaultValues: { devicePrepData: [] } })
+ return (
+ {}}
+ onPreviousStep={() => {}}
+ />
+ )
+}
+
+const openManualImport = async () => {
+ const user = userEvent.setup()
+ renderWithProviders( )
+ await user.click(await screen.findByRole('button', { name: /manual import/i }))
+ return { user, dialog: within(await screen.findByRole('dialog')) }
+}
+
+describe('CippWizardDevicePrepImport manual entry', () => {
+ it('requires manufacturer, model and serial number before a row can be added', async () => {
+ const { user, dialog } = await openManualImport()
+
+ await user.type(dialog.getByLabelText('Manufacturer'), 'Dell')
+
+ expect(await dialog.findByText(/Model, Serial Number are required/)).toBeInTheDocument()
+ expect(dialog.getByRole('button', { name: 'Add' })).toBeDisabled()
+ }, 20000)
+
+ it('rejects values containing a comma', async () => {
+ const { user, dialog } = await openManualImport()
+
+ await user.type(dialog.getByLabelText('Manufacturer'), 'Dell, Inc')
+ await user.type(dialog.getByLabelText('Model'), 'XPS 13')
+ await user.type(dialog.getByLabelText('Serial Number'), 'SN001')
+
+ expect(await dialog.findByText(/Manufacturer may not contain a comma/)).toBeInTheDocument()
+ expect(dialog.getByRole('button', { name: 'Add' })).toBeDisabled()
+ }, 20000)
+
+ it('adds a complete device to the table', async () => {
+ const { user, dialog } = await openManualImport()
+
+ await user.type(dialog.getByLabelText('Manufacturer'), 'Dell')
+ await user.type(dialog.getByLabelText('Model'), 'XPS 13')
+ await user.type(dialog.getByLabelText('Serial Number'), 'SN001')
+ await user.click(dialog.getByRole('button', { name: 'Add' }))
+
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+ // MRT virtualizes rows and jsdom has no layout engine, so cells are not
+ // rendered; the pagination summary is the observable proof the row landed.
+ expect(await screen.findByText('1-1 of 1', {}, { timeout: 10000 })).toBeInTheDocument()
+ }, 20000)
+})
+
+describe('CippWizardDevicePrepImport CSV import', () => {
+ const uploadCsv = async (content) => {
+ const user = userEvent.setup()
+ const { container } = renderWithProviders( )
+ const input = container.querySelector('input[type="file"]')
+ const file = new File([content], 'identifiers.csv', { type: 'text/csv' })
+ await user.upload(input, file)
+ }
+
+ it('imports a headerless CSV in the Intune portal order', async () => {
+ await uploadCsv('Dell,XPS 13,SN001\nHP,EliteBook,SN002\n')
+
+ expect(await screen.findByText('1-2 of 2', {}, { timeout: 10000 })).toBeInTheDocument()
+ }, 20000)
+
+ it('imports a CSV with headers', async () => {
+ await uploadCsv('manufacturer,model,serialNumber\nDell,XPS 13,SN001\n')
+
+ expect(await screen.findByText('1-1 of 1', {}, { timeout: 10000 })).toBeInTheDocument()
+ }, 20000)
+
+ it('rejects rows with missing values instead of importing them', async () => {
+ await uploadCsv('Dell,,SN001\n')
+
+ expect(await screen.findByText(/could not be imported/)).toBeInTheDocument()
+ expect(screen.getByText(/Model is required/)).toBeInTheDocument()
+ expect(screen.queryByText('1-1 of 1')).not.toBeInTheDocument()
+ }, 20000)
+
+ it('rejects duplicate devices', async () => {
+ await uploadCsv('Dell,XPS 13,SN001\nDell,XPS 13,SN001\n')
+
+ expect(await screen.findByText(/could not be imported/)).toBeInTheDocument()
+ expect(screen.getByText(/Duplicate device/)).toBeInTheDocument()
+ }, 20000)
+})
diff --git a/tests/components/CippWizard/CippWizardPage.stories.jsx b/tests/components/CippWizard/CippWizardPage.stories.jsx
new file mode 100644
index 000000000000..5fd81bb92a3b
--- /dev/null
+++ b/tests/components/CippWizard/CippWizardPage.stories.jsx
@@ -0,0 +1,83 @@
+import React from 'react'
+import { within, expect, userEvent, waitFor } from 'storybook/test'
+import { Typography } from '@mui/material'
+import CippWizardPage from '../../../src/components/CippWizard/CippWizardPage'
+import { CippWizardStepButtons } from '../../../src/components/CippWizard/CippWizardStepButtons'
+import { shrinkToPhoneViewport, growToDesktopViewport } from '../../viewport'
+
+// A step that renders nothing but the shared button row — the layout under test is the
+// wizard shell, not any particular step's form.
+const Step = (props) => (
+ <>
+ Step content
+
+ >
+)
+
+// Five steps with the real wizards' label lengths; vacation mode is exactly this shape.
+const steps = [
+ { title: 'tenant', description: 'Tenant Selection', component: Step },
+ { title: 'user', description: 'User Selection', component: Step },
+ { title: 'actions', description: 'Vacation Actions', component: Step },
+ { title: 'schedule', description: 'Schedule', component: Step },
+ { title: 'review', description: 'Review & Submit', component: Step },
+]
+
+export default {
+ title: 'Components/CippWizard/CippWizardPage',
+ component: CippWizardPage,
+ parameters: { msw: { handlers: [] } },
+}
+
+const args = { postUrl: '/api/AddVacationMode', wizardTitle: 'Vacation Mode', steps }
+
+// jsdom has no layout engine, so overflow and stacking order are invisible to the unit
+// tests. This is the only place they can be measured.
+export const PhoneWidth = {
+ render: () => ,
+ play: async ({ canvasElement }) => {
+ const onAPhone = await shrinkToPhoneViewport()
+ const canvas = within(canvasElement)
+ await canvas.findByText('Step content')
+ if (!onAPhone) return
+
+ // the stepper is replaced, not merely restyled. findBy, not getBy: useMediaQuery reacts to
+ // the resize on a later tick, and "Step content" is in both branches so it settles nothing
+ await canvas.findByText('Step 1 of 5')
+ expect(canvasElement.querySelector('.MuiStepper-root')).toBeNull()
+
+ // nothing in the card reaches past the screen
+ const card = canvasElement.querySelector('.MuiCard-root')
+ expect(card.scrollWidth).toBeLessThanOrEqual(card.clientWidth)
+
+ // advancing moves the bar
+ await userEvent.click(canvas.getByRole('button', { name: /next step/i }))
+ await waitFor(() => expect(canvas.getByText('Step 2 of 5')).toBeInTheDocument())
+
+ // column-reverse: the primary action sits above Back, and both span the card
+ const next = canvas.getByRole('button', { name: /next step/i })
+ const back = canvas.getByRole('button', { name: /^back$/i })
+ expect(back.getBoundingClientRect().top).toBeGreaterThan(next.getBoundingClientRect().top)
+ expect(next.getBoundingClientRect().width).toBeGreaterThan(
+ card.getBoundingClientRect().width * 0.7
+ )
+ },
+}
+
+// The other half of the contract: none of this reaches desktop.
+export const DesktopWidth = {
+ render: () => ,
+ play: async ({ canvasElement }) => {
+ // Claim the width rather than inherit it — PhoneWidth shares this page and shrinks it.
+ await growToDesktopViewport()
+ const canvas = within(canvasElement)
+ await canvas.findByText('Step content')
+
+ await waitFor(() => expect(canvasElement.querySelector('.MuiStepper-root')).not.toBeNull())
+ expect(canvas.queryByRole('progressbar')).toBeNull()
+ expect(canvas.queryByText('Step 1 of 5')).toBeNull()
+
+ const card = canvasElement.querySelector('.MuiCard-root')
+ expect(card.scrollWidth).toBeLessThanOrEqual(card.clientWidth)
+ },
+}
diff --git a/tests/components/CippWizard/CippWizardVacationActions.test.jsx b/tests/components/CippWizard/CippWizardVacationActions.test.jsx
index 043731c434e0..522c8e8744d5 100644
--- a/tests/components/CippWizard/CippWizardVacationActions.test.jsx
+++ b/tests/components/CippWizard/CippWizardVacationActions.test.jsx
@@ -148,6 +148,28 @@ describe('CippWizardVacationActions', () => {
).not.toBeInTheDocument()
expect(formApi.getValues('enableCAExclusion')).toBeFalsy()
})
+
+ it('offers the location alert exclusion without Conditional Access', async () => {
+ // Tenants without CA policies still get location-based audit alerts, so this switch
+ // must stand on its own rather than hide inside the CA branch.
+ renderWithProviders( )
+
+ expect(
+ screen.getByText('Exclude from location-based audit log alerts')
+ ).toBeInTheDocument()
+
+ await setField('excludeLocationAuditAlerts', true)
+
+ await waitFor(() =>
+ expect(
+ screen.getByText(/does not require a Conditional Access policy/i)
+ ).toBeInTheDocument()
+ )
+ expect(
+ screen.queryByText(/uses group-based exclusions/i)
+ ).not.toBeInTheDocument()
+ expect(formApi.getValues('enableCAExclusion')).toBeFalsy()
+ })
})
// The out-of-office branch renders a rich-text editor that does not mount under jsdom
diff --git a/tests/components/CippWizard/wizard-steps.test.jsx b/tests/components/CippWizard/wizard-steps.test.jsx
new file mode 100644
index 000000000000..19db18ab9e5f
--- /dev/null
+++ b/tests/components/CippWizard/wizard-steps.test.jsx
@@ -0,0 +1,100 @@
+import React from "react";
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { screen } from "@testing-library/react";
+import { renderWithProviders } from "../../test-utils";
+import { WizardSteps } from "../../../src/components/CippWizard/wizard-steps";
+
+// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook
+const layoutState = vi.hoisted(() => ({ isMobile: false }));
+vi.mock("../../../src/hooks/use-breakpoint", () => ({
+ useIsMobileLayout: () => layoutState.isMobile,
+ useIsTabletLayout: () => false,
+ useTableViewMode: () => "table",
+}));
+
+const steps = [
+ { title: "tenant", description: "Tenant Selection" },
+ { title: "user", description: "User Selection" },
+ { title: "actions", description: "Vacation Actions" },
+ { title: "schedule", description: "Schedule" },
+ { title: "review", description: "Review & Submit" },
+];
+
+beforeEach(() => {
+ layoutState.isMobile = false;
+});
+
+describe("WizardSteps", () => {
+ it("keeps the full stepper on desktop", () => {
+ renderWithProviders( );
+
+ expect(screen.getByText("Tenant Selection")).toBeInTheDocument();
+ expect(screen.getByText("Review & Submit")).toBeInTheDocument();
+ expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
+ });
+
+ it("collapses to a progress header on a phone", () => {
+ layoutState.isMobile = true;
+ renderWithProviders( );
+
+ expect(screen.getByText("Step 3 of 5")).toBeInTheDocument();
+ expect(screen.getByText("Vacation Actions")).toBeInTheDocument();
+ // the other four steps are not competing for the same 326px
+ expect(screen.queryByText("Tenant Selection")).not.toBeInTheDocument();
+ expect(screen.queryByText("Review & Submit")).not.toBeInTheDocument();
+
+ const bar = screen.getByRole("progressbar");
+ expect(bar).toHaveAttribute("aria-valuenow", "60");
+ });
+
+ // The vertical variant is not wizard navigation: GDAP onboarding feeds it server-side
+ // steps where each step's message and pass/fail state IS the content.
+ it("leaves the vertical status list alone on a phone", () => {
+ layoutState.isMobile = true;
+ const onboarding = [
+ { title: "invite", description: "Invite accepted", error: false },
+ { title: "roles", description: "Role assignment failed: insufficient privileges", error: true },
+ ];
+ renderWithProviders( );
+
+ expect(screen.getByText("Invite accepted")).toBeInTheDocument();
+ expect(
+ screen.getByText("Role assignment failed: insufficient privileges")
+ ).toBeInTheDocument();
+ expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
+ });
+
+ it("carries the current step's error and loading states into the bar", () => {
+ layoutState.isMobile = true;
+ const failing = [{ description: "Deploying" }, { description: "Failed", error: true }];
+ const { unmount } = renderWithProviders(
+
+ );
+ expect(screen.getByRole("progressbar").className).toMatch(/colorError/);
+ unmount();
+
+ const running = [{ description: "Deploying", loading: true }];
+ renderWithProviders( );
+ expect(screen.getByRole("progressbar").className).toMatch(/indeterminate/);
+ });
+
+ it("survives an activeStep past the end of the visible steps", () => {
+ layoutState.isMobile = true;
+ // handleNext counts against the unfiltered step list, so this really happens on wizards
+ // whose steps are conditionally hidden.
+ renderWithProviders(
+
+ );
+
+ expect(screen.getByText("Step 3 of 3")).toBeInTheDocument();
+ expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "100");
+ });
+
+ it("renders nothing broken for an empty step list", () => {
+ layoutState.isMobile = true;
+ renderWithProviders( );
+
+ expect(screen.getByText("No steps")).toBeInTheDocument();
+ expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "0");
+ });
+});
diff --git a/tests/components/ExecutiveReportButton.test.jsx b/tests/components/ExecutiveReportButton.test.jsx
index 0c522fc1d59d..cf86374b85bc 100644
--- a/tests/components/ExecutiveReportButton.test.jsx
+++ b/tests/components/ExecutiveReportButton.test.jsx
@@ -1,5 +1,5 @@
import React from 'react'
-import { screen } from '@testing-library/react'
+import { screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '../test-utils'
import { ExecutiveReportButton } from '../../src/components/ExecutiveReportButton'
@@ -99,4 +99,50 @@ describe('ExecutiveReportButton', () => {
expect(onClick).toHaveBeenCalled()
expect(await screen.findByRole('dialog')).toBeInTheDocument()
})
+
+ // The 320px config rail would leave the preview about 70px wide on a phone, so below md it
+ // moves into a drawer. Both homes render the same panel, and the toggles have to keep
+ // working from the drawer.
+ describe('section configuration on a phone', () => {
+ const openSections = async () => {
+ renderWithProviders( )
+ await userEvent.click(screen.getByRole('button', { name: /executive summary/i }))
+ await screen.findByRole('dialog')
+ await userEvent.click(screen.getByRole('button', { name: 'Report sections' }))
+ // jsdom applies no media queries, so the desktop rail is in the document too — every
+ // query here has to be scoped to the drawer or it matches both copies.
+ return within(document.querySelector('.MuiDrawer-paper'))
+ }
+
+ it('opens the sections panel in a drawer', async () => {
+ const drawer = await openSections()
+
+ expect(drawer.getByText('Report Sections')).toBeVisible()
+ expect(drawer.getByText('Executive Summary')).toBeVisible()
+ expect(drawer.getByText('Shadow AI Report')).toBeVisible()
+ })
+
+ it('toggles a section from inside the drawer', async () => {
+ const drawer = await openSections()
+
+ const deviceRow = drawer.getByText('Device Management').closest('.MuiPaper-root')
+ const toggle = within(deviceRow).getByRole('switch')
+ expect(toggle).toBeChecked()
+
+ await userEvent.click(toggle)
+
+ expect(toggle).not.toBeChecked()
+ // the footer count is the shared state both panels read
+ expect(screen.getByText(/Sections enabled: 6 of 9/)).toBeInTheDocument()
+ })
+
+ it('lifts the drawer above the dialog that opened it', async () => {
+ await openSections()
+
+ // A stock Drawer sits below a Dialog and would open behind the preview.
+ const drawer = document.querySelector('.MuiDrawer-root')
+ expect(drawer).not.toBeNull()
+ expect(window.getComputedStyle(drawer).zIndex).toBe('1301')
+ })
+ })
})
diff --git a/tests/components/PrivateRoute.test.jsx b/tests/components/PrivateRoute.test.jsx
index 14aa32ef9caa..d3872b242cf9 100644
--- a/tests/components/PrivateRoute.test.jsx
+++ b/tests/components/PrivateRoute.test.jsx
@@ -19,6 +19,7 @@ vi.mock('../../src/api/ApiCall', () => ({
// /.auth/me
return authState.swa
},
+ ApiPostCall: () => ({ mutate: vi.fn(), isPending: false }),
}))
// the gate page hosts the entire setup wizard via next/dynamic - the routing
@@ -84,6 +85,26 @@ describe('PrivateRoute', () => {
expect(screen.queryByText('app content')).not.toBeInTheDocument()
})
+ it('shows the server explanation when a signed-in identity is denied (e.g. IP blocked)', async () => {
+ // real SWA session, but CIPP refused the caller and said why - the wording must
+ // surface instead of the misleading "session expired" prompt
+ authState.swa = result({ data: swaPrincipal() })
+ authState.me = result({
+ data: {
+ clientPrincipal: null,
+ permissions: [],
+ message: 'Your IP address (203.0.113.7) is not in the allowed range for your role(s)',
+ },
+ })
+ renderRoute()
+
+ await waitFor(() => {
+ expect(screen.getByText(/not in the allowed range/)).toBeInTheDocument()
+ })
+ expect(screen.getByText('Access Denied')).toBeInTheDocument()
+ expect(screen.queryByText('Sign in to CIPP')).not.toBeInTheDocument()
+ })
+
it('shows the sign-in page when the session has no identity in either shape', async () => {
// settled /.auth/me with neither clientPrincipal nor easyauth array
authState.swa = result({ data: {} })
diff --git a/tests/components/ReleaseNotesDialog.test.jsx b/tests/components/ReleaseNotesDialog.test.jsx
new file mode 100644
index 000000000000..2468b0f51af8
--- /dev/null
+++ b/tests/components/ReleaseNotesDialog.test.jsx
@@ -0,0 +1,173 @@
+import React from 'react'
+import { screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { renderWithProviders } from '../test-utils'
+
+// public/version.json is rewritten with the image's APP_VERSION at build time, so the running
+// build's version is whatever this holds. Mutate between mounts to simulate a different build.
+const versionState = vi.hoisted(() => ({ version: '10.8.2' }))
+vi.mock('../../public/version.json', () => ({ default: versionState }))
+
+// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook
+const layoutState = vi.hoisted(() => ({ isMobile: false }))
+vi.mock('../../src/hooks/use-breakpoint', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useIsMobileLayout: () => layoutState.isMobile,
+}))
+
+vi.mock('../../src/api/ApiCall', async () => (await import('../mocks/api-call')).apiCallMock())
+
+import { api, getResult } from '../mocks/api-call'
+import { ReleaseNotesDialog } from '../../src/components/ReleaseNotesDialog'
+
+// newest first, the order GitHub returns releases in. v10.9.0 sits ahead of the running build so
+// "newest release" and "running release" can never be confused for one another.
+const RELEASES = [
+ {
+ name: 'v10.9.0 - Something Newer',
+ releaseTag: 'v10.9.0',
+ body: 'Notes for a release this instance has not been updated to yet',
+ htmlUrl: 'https://github.com/CyberDrain/CIPP/releases/tag/v10.9.0',
+ publishedAt: '2026-08-20T00:00:00Z',
+ },
+ {
+ name: 'v10.8.2 - Hotfix',
+ releaseTag: 'v10.8.2',
+ body: 'Notes for the hotfix that is actually running',
+ htmlUrl: 'https://github.com/CyberDrain/CIPP/releases/tag/v10.8.2',
+ publishedAt: '2026-08-08T00:36:06Z',
+ },
+ {
+ name: 'v10.8.0 - Ramos Melon Fizz',
+ releaseTag: 'v10.8.0',
+ body: 'Notes for the base release of the 10.8 series',
+ htmlUrl: 'https://github.com/CyberDrain/CIPP/releases/tag/v10.8.0',
+ publishedAt: '2026-08-07T17:01:49Z',
+ },
+]
+
+// stable identity, a fresh literal per mock call loops CippAutoComplete's mapping effect
+const catalogResult = getResult({ data: RELEASES })
+
+const COOKIE_KEY = 'cipp_release_notice'
+const PERMANENT_HIDE_KEY = 'cipp_release_notice_permanently_hidden'
+
+const flushEffects = () => new Promise((resolve) => setTimeout(resolve, 0))
+
+beforeEach(() => {
+ layoutState.isMobile = false
+ versionState.version = '10.8.2'
+ api.get = catalogResult
+ window.localStorage.clear()
+ document.cookie = `${COOKIE_KEY}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`
+})
+
+describe('ReleaseNotesDialog', () => {
+ // A hotfix release body is only the delta since the feature release, so opening on it tells
+ // the user almost nothing. Display the newest vX.Y.0 instead — dismissal still tracks the
+ // running tag, which is what the reopen-forever bug hinged on (see the next test).
+ it('opens on the newest .0 release, not on a hotfix', async () => {
+ renderWithProviders( )
+
+ expect(
+ await screen.findByDisplayValue('v10.9.0 - Something Newer')
+ ).toBeInTheDocument()
+ expect(screen.queryByText('Notes for the hotfix that is actually running')).toBeNull()
+ })
+
+ it('still lets you pick a hotfix release from the picker', async () => {
+ const user = userEvent.setup()
+ renderWithProviders( )
+ await screen.findByDisplayValue('v10.9.0 - Something Newer')
+
+ await user.click(screen.getByRole('combobox'))
+ await user.click(await screen.findByText('v10.8.2 - Hotfix'))
+
+ expect(
+ await screen.findByText('Notes for the hotfix that is actually running')
+ ).toBeInTheDocument()
+ })
+
+ it('stays dismissed on reload after "Don\'t show until next release"', async () => {
+ const user = userEvent.setup()
+
+ const { unmount } = renderWithProviders( )
+ await screen.findByDisplayValue('v10.9.0 - Something Newer')
+ await user.click(screen.getByRole('button', { name: "Don't show until next release" }))
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
+
+ // the tag of the build being run, not the newest tag on GitHub - storing v10.9.0 here left a
+ // cookie the eligibility check could never match, so the dialog reopened on every page load
+ expect(document.cookie).toContain(`${COOKIE_KEY}=v10.8.2`)
+
+ unmount()
+ renderWithProviders( )
+ await flushEffects()
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+
+ it('opens again once the instance is updated to a newer release', async () => {
+ document.cookie = `${COOKIE_KEY}=v10.8.2; path=/`
+ versionState.version = '10.9.0'
+
+ renderWithProviders( )
+
+ expect(await screen.findByDisplayValue('v10.9.0 - Something Newer')).toBeInTheDocument()
+ })
+
+ // On phones the two low-emphasis actions live behind the kebab as bottom-sheet rows —
+ // the same actions treatment as the rest of the mobile surface.
+ it('puts GitHub and permanent dismiss behind the kebab sheet on mobile', async () => {
+ layoutState.isMobile = true
+ const user = userEvent.setup()
+ renderWithProviders( )
+ // the house pick-one pattern: a trigger, not a text input — no keyboard to summon
+ const trigger = await screen.findByRole('button', { name: /switch release/i })
+ expect(trigger).toHaveTextContent('v10.9.0 - Something Newer')
+ expect(screen.queryByDisplayValue('v10.9.0 - Something Newer')).not.toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'More options' }))
+ // the desktop footer's copy is only display:none'd by a media query jsdom can't
+ // evaluate — scope to the sheet's drawer paper
+ const github = await screen.findByRole('link', { name: /view release notes on github/i })
+ expect(github).toHaveAttribute('href', 'https://github.com/CyberDrain/CIPP/releases/tag/v10.9.0')
+ const sheet = within(github.closest('.MuiDrawer-paper'))
+
+ await user.click(sheet.getByText("Don't show again"))
+ await flushEffects()
+ expect(window.localStorage.getItem(PERMANENT_HIDE_KEY)).toBe('true')
+ })
+
+ it('switches release from the mobile sheet', async () => {
+ layoutState.isMobile = true
+ const user = userEvent.setup()
+ renderWithProviders( )
+
+ await user.click(await screen.findByRole('button', { name: /switch release/i }))
+ const sheet = within((await screen.findByText('Release')).closest('.MuiDrawer-paper'))
+ await user.click(sheet.getByText('v10.8.2 - Hotfix'))
+
+ expect(await screen.findByText('Notes for the hotfix that is actually running')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /switch release/i })).toHaveTextContent(
+ 'v10.8.2 - Hotfix'
+ )
+ })
+
+ it('falls back to the .0 notes when the running version has no release of its own', async () => {
+ versionState.version = '10.9.1'
+
+ renderWithProviders( )
+
+ expect(await screen.findByDisplayValue('v10.9.0 - Something Newer')).toBeInTheDocument()
+ })
+
+ it('honours a permanent dismissal', async () => {
+ window.localStorage.setItem(PERMANENT_HIDE_KEY, 'true')
+
+ renderWithProviders( )
+ await flushEffects()
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+})
diff --git a/tests/contexts/tutorial-context.test.jsx b/tests/contexts/tutorial-context.test.jsx
new file mode 100644
index 000000000000..5411488ab20d
--- /dev/null
+++ b/tests/contexts/tutorial-context.test.jsx
@@ -0,0 +1,41 @@
+import { render, screen } from '@testing-library/react'
+import { TutorialProvider, useTutorials } from '../../src/contexts/tutorial-context'
+
+// TutorialProvider loads its tours through webpack's require.context, which vite has no
+// equivalent for. tests/mocks/require-context.js maps it onto import.meta.glob, so this
+// render is what proves the polyfill actually reaches a src module.
+const TutorialProbe = () => {
+ const { tutorials, getTutorialsForPage } = useTutorials()
+ return (
+ <>
+ {tutorials.map((t) => t.id).join(',')}
+ {getTutorialsForPage('/').map((t) => t.id).join(',')}
+ >
+ )
+}
+
+describe('TutorialProvider', () => {
+ it('loads the tutorial json off require.context', () => {
+ render(
+
+
+
+ )
+
+ const ids = screen.getByTestId('ids').textContent.split(',')
+ expect(ids).toEqual(
+ expect.arrayContaining(['getting-started', 'dashboard-overview', 'tenant-management'])
+ )
+ })
+
+ it('scopes tutorials to the page they declare', () => {
+ render(
+
+
+
+ )
+
+ // getting-started declares pages: ['/'], the other two declare other routes
+ expect(screen.getByTestId('home').textContent).toBe('getting-started')
+ })
+})
diff --git a/tests/hooks/use-actions-dispatch.test.jsx b/tests/hooks/use-actions-dispatch.test.jsx
new file mode 100644
index 000000000000..41226846eb64
--- /dev/null
+++ b/tests/hooks/use-actions-dispatch.test.jsx
@@ -0,0 +1,172 @@
+import React from "react";
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { renderWithProviders } from "../test-utils";
+import { useActionsDispatch } from "../../src/hooks/use-actions-dispatch";
+import { CippApiDialog } from "../../src/components/CippComponents/CippApiDialog";
+
+// Stable identities: a fresh object per call changes on every render and spins a loop.
+// CippApiDialog calls reset() on open, so the post result needs the full shape.
+const idlePost = vi.hoisted(() => ({
+ mutate: vi.fn(),
+ reset: vi.fn(),
+ isPending: false,
+ isSuccess: false,
+ isError: false,
+ data: undefined,
+ error: null,
+}));
+const idleGet = vi.hoisted(() => ({
+ data: undefined,
+ isFetching: false,
+ isLoading: false,
+ isSuccess: false,
+ isError: false,
+ refetch: vi.fn(),
+}));
+const idlePaginated = vi.hoisted(() => ({
+ data: undefined,
+ isFetching: false,
+ isSuccess: false,
+ isError: false,
+ fetchNextPage: vi.fn(),
+ refetch: vi.fn(),
+}));
+const postOptions = vi.hoisted(() => []);
+vi.mock("../../src/api/ApiCall", () => ({
+ ApiPostCall: (options) => {
+ postOptions.push(options);
+ return idlePost;
+ },
+ ApiGetCall: () => idleGet,
+ ApiGetCallWithPagination: () => idlePaginated,
+}));
+
+// `dialog` is a fragment holding whichever surface the action needs, so reach past it.
+const dialogPropsOf = (dialog) =>
+ React.Children.toArray(dialog?.props?.children).find((child) => child?.type === CippApiDialog)
+ ?.props;
+
+const Harness = ({ actions, data = { id: "1" }, queryKeys, onDialogProps }) => {
+ const { visibleActions, dispatch, dialog } = useActionsDispatch({ actions, data, queryKeys });
+ onDialogProps?.(dialogPropsOf(dialog));
+ return (
+ <>
+ {visibleActions.map((action) => (
+ dispatch(action)}>
+ {action.label}
+
+ ))}
+ {dialog}
+ >
+ );
+};
+
+beforeEach(() => {
+ idlePost.mutate.mockClear();
+ postOptions.length = 0;
+});
+
+describe("useActionsDispatch", () => {
+ // The hook set ready:true before branching, which mounted CippApiDialog with
+ // api.noConfirm true; the dialog's mount effect then auto-submitted into the same
+ // customFunction the hook had just called directly.
+ it("runs a noConfirm customFunction exactly once per tap", async () => {
+ const user = userEvent.setup();
+ const customFunction = vi.fn();
+ renderWithProviders(
+
+ );
+
+ await user.click(screen.getByRole("button", { name: "Refresh Data" }));
+
+ await waitFor(() => expect(customFunction).toHaveBeenCalledTimes(1));
+ // and it stays at one — the auto-submit effect must not fire on a later commit
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ expect(customFunction).toHaveBeenCalledTimes(1);
+ });
+
+ // The dialog instance was reused and its auto-submit effect keys on
+ // [api.noConfirm, api.link], so a repeat of the same action left the deps unchanged and
+ // silently did nothing.
+ it("runs again when the same action is dispatched twice", async () => {
+ const user = userEvent.setup();
+ const customFunction = vi.fn();
+ renderWithProviders(
+
+ );
+
+ await user.click(screen.getByRole("button", { name: "Refresh Data" }));
+ await waitFor(() => expect(customFunction).toHaveBeenCalledTimes(1));
+ await user.click(screen.getByRole("button", { name: "Refresh Data" }));
+
+ await waitFor(() => expect(customFunction).toHaveBeenCalledTimes(2));
+ });
+
+ it("passes the caller's queryKeys through to the dialog", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(
+
+ );
+
+ await user.click(screen.getByRole("button", { name: "Edit" }));
+
+ // The dialog builds its mutation from relatedQueryKeys; without it the invalidation
+ // falls back to the hardcoded "Confirmation" title and the page never refreshes.
+ await waitFor(() => {
+ expect(postOptions.at(-1)?.relatedQueryKeys).toBe("Tenant History");
+ });
+ });
+
+ // The action was spread last, so any key it happened to carry silently beat the explicit
+ // prop — and every unknown key was forwarded onto the DOM by CippApiDialog.
+ it("does not let the action object override explicit dialog props", async () => {
+ const user = userEvent.setup();
+ const props = vi.fn();
+ renderWithProviders(
+
+ );
+
+ await user.click(screen.getByRole("button", { name: "Edit" }));
+
+ await waitFor(() => {
+ const last = props.mock.calls.at(-1)?.[0];
+ expect(last?.row).toEqual({ id: "42" });
+ });
+ });
+
+ it("drops the dialog again once it closes", async () => {
+ const user = userEvent.setup();
+ const props = vi.fn();
+ renderWithProviders(
+
+ );
+
+ await user.click(screen.getByRole("button", { name: "Edit" }));
+ await waitFor(() => expect(props.mock.calls.at(-1)?.[0]).toBeTruthy());
+
+ await user.keyboard("{Escape}");
+
+ // Left mounted, it holds a live mutation, an API subscription and a form instance for
+ // as long as the page lives — and on HeaderedTabbedLayout the page never unmounts.
+ await waitFor(() => expect(props.mock.calls.at(-1)?.[0]).toBeUndefined());
+ });
+
+ it("hands a customComponent action to that component instead of a confirm dialog", async () => {
+ const user = userEvent.setup();
+ const customComponent = vi.fn(() => custom surface
);
+ renderWithProviders( );
+
+ await user.click(screen.getByRole("button", { name: "Open" }));
+
+ expect(await screen.findByTestId("custom")).toBeInTheDocument();
+ });
+});
diff --git a/tests/hooks/use-breakpoint.test.jsx b/tests/hooks/use-breakpoint.test.jsx
new file mode 100644
index 000000000000..ed8d92fbb183
--- /dev/null
+++ b/tests/hooks/use-breakpoint.test.jsx
@@ -0,0 +1,106 @@
+import React from 'react'
+import { screen } from '@testing-library/react'
+import { renderWithProviders, settingsWith } from '../test-utils'
+import { useIsMobileLayout, useTableViewMode } from '../../src/hooks/use-breakpoint'
+
+// jsdom has no width-based matchMedia, so useIsMobileLayout is always false here —
+// which is exactly why the explicit settings/prop path must exist and is what we test.
+const Probe = (props) => {useTableViewMode(props)}
+
+const renderMode = (props, settings) =>
+ renderWithProviders( , settings ? { settings: settingsWith(settings) } : undefined)
+
+describe('useTableViewMode', () => {
+ it("defaults to 'table' on desktop-width (auto + not mobile)", () => {
+ renderMode()
+ expect(screen.getByTestId('mode')).toHaveTextContent('table')
+ })
+
+ it('settings.tableViewMode=cards forces cards', () => {
+ renderMode({}, { tableViewMode: 'cards' })
+ expect(screen.getByTestId('mode')).toHaveTextContent('cards')
+ })
+
+ it('accepts {value,label} shaped settings', () => {
+ renderMode({}, { tableViewMode: { value: 'cards', label: 'Card list' } })
+ expect(screen.getByTestId('mode')).toHaveTextContent('cards')
+ })
+
+ it('per-call viewMode prop beats settings', () => {
+ renderMode({ viewMode: 'table' }, { tableViewMode: 'cards' })
+ expect(screen.getByTestId('mode')).toHaveTextContent('table')
+ })
+
+ it('simple always forces table, even against explicit cards', () => {
+ renderMode({ viewMode: 'cards', simple: true }, { tableViewMode: 'cards' })
+ expect(screen.getByTestId('mode')).toHaveTextContent('table')
+ })
+
+ it('invalid mode values fall back to auto behavior', () => {
+ renderMode({}, { tableViewMode: 'bogus' })
+ expect(screen.getByTestId('mode')).toHaveTextContent('table')
+ })
+})
+
+// Width-aware stub so the two thresholds can be told apart. MUI asks in '@media (max-width:Npx)'
+// form; anything it doesn't ask about is left unmatched.
+const atWidth = (width) => {
+ const cache = new Map()
+ window.matchMedia = (query) => {
+ if (!cache.has(query)) {
+ const max = /max-width:\s*([\d.]+)px/.exec(query)
+ const min = /min-width:\s*([\d.]+)px/.exec(query)
+ cache.set(query, {
+ matches: (!max || width <= parseFloat(max[1])) && (!min || width >= parseFloat(min[1])),
+ media: query,
+ onchange: null,
+ addListener: () => {},
+ removeListener: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ dispatchEvent: () => false,
+ })
+ }
+ return cache.get(query)
+ }
+}
+
+afterEach(() => {
+ delete window.matchMedia
+})
+
+const SplitProbe = () => (
+ <>
+ {String(useIsMobileLayout())}
+ {useTableViewMode()}
+ >
+)
+
+// The two thresholds are deliberately different. One query for both breaks an end either way:
+// at md the 900-1200 band loses the side nav with no hamburger to open the drawer, at lg
+// desktop-width tables become card lists.
+describe('the chrome/table split', () => {
+ it('treats the 900-1200 band as mobile chrome but keeps tables tabular', () => {
+ atWidth(1000)
+ renderWithProviders( )
+
+ expect(screen.getByTestId('chrome')).toHaveTextContent('true')
+ expect(screen.getByTestId('mode')).toHaveTextContent('table')
+ })
+
+ it('moves both to mobile on a phone', () => {
+ atWidth(800)
+ renderWithProviders( )
+
+ expect(screen.getByTestId('chrome')).toHaveTextContent('true')
+ expect(screen.getByTestId('mode')).toHaveTextContent('cards')
+ })
+
+ it('leaves both on desktop above lg', () => {
+ atWidth(1300)
+ renderWithProviders( )
+
+ expect(screen.getByTestId('chrome')).toHaveTextContent('false')
+ expect(screen.getByTestId('mode')).toHaveTextContent('table')
+ })
+})
diff --git a/tests/hooks/use-history-dismiss.test.jsx b/tests/hooks/use-history-dismiss.test.jsx
new file mode 100644
index 000000000000..b3bb32e8aa4d
--- /dev/null
+++ b/tests/hooks/use-history-dismiss.test.jsx
@@ -0,0 +1,93 @@
+import React, { useState } from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { act, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { renderWithProviders } from "../test-utils";
+import { useHistoryDismiss } from "../../src/hooks/use-history-dismiss";
+import { resetOverlayHistory } from "../../src/utils/overlay-history";
+
+const nextPop = () =>
+ new Promise((resolve) => window.addEventListener("popstate", resolve, { once: true }));
+
+// The back gesture is history navigation; jsdom traverses asynchronously, and the resulting
+// state update belongs inside act().
+const swipeBack = async () => {
+ await act(async () => {
+ const settled = nextPop();
+ window.history.back();
+ await settled;
+ });
+};
+
+const Harness = ({ enabled = true, onClose }) => {
+ const [open, setOpen] = useState(false);
+ const close = () => {
+ setOpen(false);
+ onClose?.();
+ };
+ useHistoryDismiss(open, close, enabled);
+ return (
+ <>
+ setOpen(true)}>
+ Open details
+
+ {open && (
+ <>
+ Row details
+
+ Close details
+
+ >
+ )}
+ >
+ );
+};
+
+afterEach(() => {
+ resetOverlayHistory();
+});
+
+describe("useHistoryDismiss", () => {
+ it("dismisses the overlay on a back press instead of navigating the page", async () => {
+ const user = userEvent.setup();
+ renderWithProviders( );
+
+ await user.click(screen.getByRole("button", { name: "Open details" }));
+ expect(screen.getByTestId("overlay")).toBeInTheDocument();
+
+ await swipeBack();
+
+ expect(screen.queryByTestId("overlay")).not.toBeInTheDocument();
+ });
+
+ it("gives the entry back when the overlay closes on its own", async () => {
+ const user = userEvent.setup();
+ const onClose = vi.fn();
+ renderWithProviders( );
+
+ await user.click(screen.getByRole("button", { name: "Open details" }));
+ await act(async () => {
+ const settled = nextPop();
+ await user.click(screen.getByRole("button", { name: "Close details" }));
+ await settled;
+ });
+
+ // Closed once, by the button — and the history entry went with it, so the next back
+ // press is the page's again rather than a dead tap.
+ expect(onClose).toHaveBeenCalledTimes(1);
+ expect(window.history.state?.__cippOverlay).toBeUndefined();
+ });
+
+ it("stays out of history when disabled", async () => {
+ const user = userEvent.setup();
+ renderWithProviders( );
+
+ await user.click(screen.getByRole("button", { name: "Open details" }));
+ // Somewhere to go back to, so the gesture is a real navigation attempt.
+ window.history.pushState({}, "");
+ await swipeBack();
+
+ // Desktop keeps today's behaviour: back belongs to the router, not the overlay.
+ expect(screen.getByTestId("overlay")).toBeInTheDocument();
+ });
+});
diff --git a/tests/hooks/use-sheet-handoff.test.jsx b/tests/hooks/use-sheet-handoff.test.jsx
new file mode 100644
index 000000000000..5d9276ef5987
--- /dev/null
+++ b/tests/hooks/use-sheet-handoff.test.jsx
@@ -0,0 +1,64 @@
+import React from "react";
+import { describe, it, expect, vi } from "vitest";
+import { screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { ListItemButton, ListItemText } from "@mui/material";
+import { renderWithProviders } from "../test-utils";
+import { CippBottomSheet } from "../../src/components/CippComponents/CippBottomSheet";
+import { useSheetHandoff } from "../../src/hooks/use-sheet-handoff";
+
+// A sheet row that closes the sheet and opens another Modal in the same tick leaves two
+// Modals in flight; the outgoing Drawer restores scroll lock and aria-hidden on top of the
+// overlay that just opened. The handoff waits for the exit before running the action.
+const Harness = ({ onAction }) => {
+ const [open, setOpen] = React.useState(false);
+ const sheet = useSheetHandoff(() => setOpen(false));
+ return (
+ <>
+ setOpen(true)}>
+ Open sheet
+
+
+ sheet.run(onAction)}>
+
+
+
+ >
+ );
+};
+
+describe("useSheetHandoff", () => {
+ it("runs the action only after the sheet has finished closing", async () => {
+ const user = userEvent.setup();
+ const onAction = vi.fn();
+ renderWithProviders( );
+
+ await user.click(screen.getByRole("button", { name: "Open sheet" }));
+ await user.click(await screen.findByText("Do the thing"));
+
+ // the tap closes the sheet immediately, but the action is still parked
+ expect(onAction).not.toHaveBeenCalled();
+
+ await waitFor(() => expect(onAction).toHaveBeenCalledTimes(1));
+ expect(screen.queryByText("Do the thing")).not.toBeInTheDocument();
+ });
+
+ it("drops the parked action when the sheet is dismissed instead", async () => {
+ const user = userEvent.setup();
+ const onAction = vi.fn();
+ renderWithProviders( );
+
+ await user.click(screen.getByRole("button", { name: "Open sheet" }));
+ await screen.findByText("Do the thing");
+ await user.keyboard("{Escape}");
+
+ await waitFor(() => expect(screen.queryByText("Do the thing")).not.toBeInTheDocument());
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ expect(onAction).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/layouts/AccountPopover.test.jsx b/tests/layouts/AccountPopover.test.jsx
new file mode 100644
index 000000000000..ebf14be9b693
--- /dev/null
+++ b/tests/layouts/AccountPopover.test.jsx
@@ -0,0 +1,99 @@
+import React from "react";
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { renderWithProviders } from "../test-utils";
+import { cippPrincipal } from "../mocks/fixtures";
+
+vi.mock("next/navigation", () => ({
+ usePathname: () => "/",
+ useRouter: () => ({ push: vi.fn() }),
+}));
+
+// jsdom has no width-based matchMedia, so the nav pivot is driven by mocking the hook, and
+// MUI's own useMediaQuery answers false there, i.e. the >= md side of the popover's mdDown
+// gate. that pairing is the 900-1199 band: nav collapsed, still above md.
+const layoutState = vi.hoisted(() => ({ isMobile: false }));
+vi.mock("../../src/hooks/use-breakpoint", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useIsMobileLayout: () => layoutState.isMobile,
+}));
+
+// stable identities, a fresh object per call re-renders forever
+const idle = vi.hoisted(() => ({
+ isSuccess: false,
+ isFetching: false,
+ isPending: false,
+ isError: false,
+ data: undefined,
+ mutate: () => {},
+ reset: () => {},
+ refetch: () => {},
+}));
+const meResult = vi.hoisted(() => ({
+ isSuccess: true,
+ isFetching: false,
+ isPending: false,
+ isError: false,
+ data: undefined,
+ refetch: () => {},
+}));
+vi.mock("../../src/api/ApiCall", () => ({
+ ApiGetCall: ({ url }) => (url === "/api/me" ? meResult : idle),
+ ApiPostCall: () => idle,
+ ApiGetCallWithPagination: () => ({ ...idle, fetchNextPage: () => {} }),
+}));
+
+import { AccountPopover } from "../../src/layouts/account-popover";
+
+const renderPopover = () => {
+ const onThemeSwitch = vi.fn();
+ const onOpenSearch = vi.fn();
+ renderWithProviders(
+
+ );
+ return { onThemeSwitch, onOpenSearch };
+};
+
+// avatar fallback glyph for john@contoso.com, the popover's only trigger
+const openPopover = async () => userEvent.click(await screen.findByText("J"));
+
+describe("AccountPopover", () => {
+ beforeEach(() => {
+ layoutState.isMobile = false;
+ meResult.data = cippPrincipal(["editor"]);
+ });
+
+ it("offers universal search and the theme toggle whenever the top bar hides their icons", async () => {
+ layoutState.isMobile = true;
+ const { onThemeSwitch, onOpenSearch } = renderPopover();
+
+ await openPopover();
+ await userEvent.click(screen.getByText("Universal Search"));
+ expect(onOpenSearch).toHaveBeenCalled();
+
+ await openPopover();
+ await userEvent.click(screen.getByText("Dark Mode"));
+ expect(onThemeSwitch).toHaveBeenCalled();
+ });
+
+ it("leaves search and theme to the top bar while it still renders their icons", async () => {
+ renderPopover();
+
+ await openPopover();
+ expect(screen.queryByText("Universal Search")).toBeNull();
+ expect(screen.queryByText("Dark Mode")).toBeNull();
+ });
+
+ it("does not repeat the signed-in identity that the trigger is already showing", async () => {
+ layoutState.isMobile = true;
+ renderPopover();
+
+ await openPopover();
+ expect(screen.getAllByText("john@contoso.com")).toHaveLength(1);
+ });
+});
diff --git a/tests/layouts/HeaderedTabbedLayout.test.jsx b/tests/layouts/HeaderedTabbedLayout.test.jsx
new file mode 100644
index 000000000000..c3cfeb1b7346
--- /dev/null
+++ b/tests/layouts/HeaderedTabbedLayout.test.jsx
@@ -0,0 +1,127 @@
+import React from "react";
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { renderWithProviders } from "../test-utils";
+
+// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook
+const layoutState = vi.hoisted(() => ({ mdDown: true }));
+vi.mock("../../src/hooks/use-breakpoint", () => ({
+ useIsMobileLayout: () => layoutState.mdDown,
+ useIsTabletLayout: () => false,
+ useTableViewMode: () => "table",
+}));
+
+vi.mock("next/router", () => ({
+ useRouter: () => ({ query: {}, push: vi.fn(), pathname: "/tenant/manage/edit" }),
+}));
+vi.mock("next/navigation", () => ({ usePathname: () => "/tenant/manage/edit" }));
+
+const idle = vi.hoisted(() => ({
+ isSuccess: false,
+ isFetching: false,
+ isPending: false,
+ isError: false,
+ data: undefined,
+ mutate: () => {},
+ reset: () => {},
+ refetch: () => {},
+}));
+vi.mock("../../src/api/ApiCall", () => ({
+ ApiGetCall: () => idle,
+ ApiPostCall: () => idle,
+ ApiGetCallWithPagination: () => ({ ...idle, fetchNextPage: () => {} }),
+}));
+
+import { HeaderedTabbedLayout } from "../../src/layouts/HeaderedTabbedLayout";
+
+const tabOptions = [
+ { label: "Edit Tenant", path: "/tenant/manage/edit", icon: "Settings" },
+ { label: "Manage Drift", path: "/tenant/manage/drift", icon: "Sync" },
+];
+
+const actions = [
+ {
+ label: "Reset Password",
+ type: "POST",
+ url: "/api/ExecResetPass",
+ confirmText: "Reset the password?",
+ },
+];
+
+const renderLayout = (props = {}) =>
+ renderWithProviders(
+
+ page content
+
+ );
+
+describe("HeaderedTabbedLayout mobile header", () => {
+ beforeEach(() => {
+ layoutState.mdDown = true;
+ });
+
+ it("keeps the header Actions menu on desktop and drops it on mobile", async () => {
+ renderLayout();
+ expect(screen.queryByRole("button", { name: "Actions" })).not.toBeInTheDocument();
+
+ layoutState.mdDown = false;
+ renderLayout();
+ await waitFor(() =>
+ expect(screen.getAllByRole("button", { name: "Actions" }).length).toBeGreaterThan(0)
+ );
+ });
+
+ // The title row's right half is empty below md — that is the slot the picker takes, so
+ // navigation costs no vertical space and does not depend on a FAB being on screen.
+ it("puts the tab picker in the title row on mobile, and tabs on desktop", async () => {
+ renderLayout();
+ const picker = screen.getByRole("button", { name: /switch view/i });
+ expect(picker).toHaveAccessibleName("Edit Tenant switch view");
+ expect(screen.queryByRole("tab")).not.toBeInTheDocument();
+
+ const user = userEvent.setup();
+ await user.click(picker);
+ const sheet = within((await screen.findByText("Views")).closest(".MuiDrawer-paper"));
+ expect(sheet.getByText("Manage Drift")).toBeInTheDocument();
+
+ layoutState.mdDown = false;
+ renderLayout();
+ await waitFor(() =>
+ expect(screen.getByRole("tab", { name: /Manage Drift/ })).toBeInTheDocument()
+ );
+ });
+
+ // A FAB is for actions. With none to carry there is nothing to put in the corner.
+ it("renders no FAB when the page has no actions", () => {
+ renderLayout({ actions: [] });
+ expect(screen.queryByRole("button", { name: /Page actions/ })).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /switch view/i })).toBeInTheDocument();
+ });
+
+ // The sheet closing and the overlay opening happen in one tick; MUI's modal manager has
+ // to settle the unmounting Drawer before the new one registers, or the overlay never
+ // becomes interactive.
+ it("opens the action's overlay from the sheet and leaves it open", async () => {
+ const user = userEvent.setup();
+ renderLayout();
+
+ await user.click(screen.getByRole("button", { name: "Page actions" }));
+ await user.click(await screen.findByText("Reset Password"));
+
+ // sheet goes away — keepMounted keeps its rows in the DOM, so closed means hidden
+ await waitFor(() => expect(screen.getByText("Reset Password")).not.toBeVisible());
+
+ // and the confirmation overlay is present and stays present
+ const confirm = await screen.findByText(/Reset the password\?/i, {}, { timeout: 3000 });
+ expect(confirm).toBeInTheDocument();
+ await new Promise((resolve) => setTimeout(resolve, 400));
+ expect(screen.getByText(/Reset the password\?/i)).toBeInTheDocument();
+ });
+});
diff --git a/tests/layouts/MobileNav.stories.jsx b/tests/layouts/MobileNav.stories.jsx
new file mode 100644
index 000000000000..1c4977bbc1ea
--- /dev/null
+++ b/tests/layouts/MobileNav.stories.jsx
@@ -0,0 +1,189 @@
+import React, { useState } from 'react'
+import { within, expect, userEvent, waitFor } from 'storybook/test'
+import { Box, Button } from '@mui/material'
+import { MobileNav } from '../../src/layouts/mobile-nav'
+import { shrinkToPhoneViewport } from '../viewport'
+
+const items = [
+ { title: 'Dashboard', path: '/' },
+ {
+ title: 'Identity Management',
+ path: '/identity',
+ items: [
+ { title: 'Users', path: '/identity/administration/users' },
+ { title: 'Groups', path: '/identity/administration/groups' },
+ { title: 'Devices', path: '/identity/administration/devices' },
+ ],
+ },
+ {
+ title: 'Tenant Administration',
+ path: '/tenant',
+ items: [
+ { title: 'Tenants', path: '/tenant/administration/tenants' },
+ { title: 'Alerts', path: '/tenant/administration/alert-configuration' },
+ ],
+ },
+ { title: 'Tools', path: '/tools' },
+ { title: 'Settings', path: '/cipp/settings' },
+]
+
+// Mirrors the open/close state Layout owns (layouts/index.js useMobileNav), so the drawer
+// behaves here exactly as it does in the app.
+const Harness = (props) => {
+ const [open, setOpen] = useState(false)
+ return (
+
+ setOpen(true)}>
+ Open nav
+
+ setOpen(true)}
+ onClose={() => setOpen(false)}
+ {...props}
+ />
+
+ )
+}
+
+export default {
+ title: 'Layouts/MobileNav',
+ component: MobileNav,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'fullscreen',
+ },
+}
+
+export const Default = {
+ render: () => ,
+}
+
+// Only a real browser can settle this: jsdom runs no transitions, so the frame the close
+// animation starts from does not exist there.
+export const DragClosesFromWhereItWasLeft = {
+ render: () => ,
+ play: async ({ canvasElement }) => {
+ const onAPhone = await shrinkToPhoneViewport()
+ const canvas = within(canvasElement)
+
+ await userEvent.click(canvas.getByTestId('open-nav'))
+ const paper = await waitFor(() => {
+ const node = document.querySelector('.MuiDrawer-paper')
+ expect(node).not.toBeNull()
+ return node
+ })
+ if (!onAPhone) {
+ return
+ }
+ await waitFor(() =>
+ expect(new DOMMatrixReadOnly(getComputedStyle(paper).transform).m41).toBe(0)
+ )
+
+ // Dispatched on a node inside the paper and left to bubble: MUI reads event.target to
+ // decide the gesture started in the drawer, so firing at the document bails immediately.
+ const target = paper.querySelector('nav') ?? paper
+ const at = (clientX) =>
+ new Touch({ identifier: 1, target, clientX, clientY: 400, pageX: clientX, pageY: 400 })
+ const fire = (type, clientX) =>
+ target.dispatchEvent(
+ new TouchEvent(type, {
+ bubbles: true,
+ cancelable: true,
+ touches: type === 'touchend' ? [] : [at(clientX)],
+ changedTouches: [at(clientX)],
+ })
+ )
+
+ // MUI flags "maybe swiping" in React state on touchstart and ignores moves until that has
+ // been applied, so the gesture has to be spread across ticks like a real one.
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 30))
+ fire('touchstart', 300)
+ await tick()
+ for (const x of [285, 230, 160, 80, 40]) {
+ fire('touchmove', x)
+ await tick()
+ }
+
+ const draggedTo = new DOMMatrixReadOnly(getComputedStyle(paper).transform).m41
+ expect(draggedTo).toBeLessThan(-100)
+ fire('touchend', 40)
+
+ // The exit has to continue from where the finger let go. Slide probes the paper's
+ // untranslated position when the exit starts (Slide.js getTranslateValue), and the browser
+ // takes that probe as the transition's start, which snaps the drawer wide open first.
+ const firstExitFrame = await new Promise((resolve) => {
+ requestAnimationFrame(() =>
+ requestAnimationFrame(() =>
+ resolve(new DOMMatrixReadOnly(getComputedStyle(paper).transform).m41)
+ )
+ )
+ })
+ expect(firstExitFrame).toBeLessThan(draggedTo * 0.6)
+
+ await waitFor(() =>
+ expect(document.querySelector('.MuiDrawer-root').getAttribute('aria-hidden')).toBe('true')
+ )
+ },
+}
+
+// enough rows to overflow a phone-height drawer once the group is expanded
+const tallItems = [
+ { title: 'Dashboard', path: '/' },
+ {
+ title: 'CIPP',
+ path: '/cipp',
+ items: [
+ { title: 'Custom Data', path: '/cipp/custom-data' },
+ {
+ title: 'Advanced',
+ path: '/cipp/advanced',
+ items: [
+ { title: 'Super Admin', path: '/cipp/advanced/super-admin/tenant-mode' },
+ { title: 'Container Management', path: '/cipp/advanced/container-management/status' },
+ { title: 'Authentication', path: '/cipp/advanced/authentication' },
+ { title: 'Timers', path: '/cipp/advanced/timers' },
+ ],
+ },
+ { title: 'Settings', path: '/cipp/settings' },
+ { title: 'Preferences', path: '/cipp/preferences' },
+ ],
+ },
+ ...Array.from({ length: 14 }, (_, index) => ({
+ title: `Section ${index + 1}`,
+ path: `/section-${index + 1}`,
+ })),
+]
+
+export const NavListIsTheOnlyScroller = {
+ render: () => ,
+ play: async ({ canvasElement }) => {
+ const onAPhone = await shrinkToPhoneViewport()
+ const canvas = within(canvasElement)
+
+ await userEvent.click(canvas.getByTestId('open-nav'))
+ const paper = await waitFor(() => {
+ const node = document.querySelector('.MuiDrawer-paper')
+ expect(node).not.toBeNull()
+ return node
+ })
+ if (!onAPhone) {
+ return
+ }
+ await waitFor(() =>
+ expect(new DOMMatrixReadOnly(getComputedStyle(paper).transform).m41).toBe(0)
+ )
+
+ await userEvent.click(await within(paper).findByText('CIPP'))
+
+ // the list has to overflow, or the paper assertion below would pass for the wrong reason
+ const scroller = paper.querySelector('.simplebar-content-wrapper')
+ await waitFor(() =>
+ expect(scroller.scrollHeight).toBeGreaterThan(scroller.clientHeight + 200)
+ )
+
+ // a scrollable paper carries the pinned sponsor up with it and leaves blank drawer below
+ expect(paper.scrollHeight).toBeLessThanOrEqual(paper.clientHeight + 1)
+ },
+}
diff --git a/tests/layouts/MobileNav.test.jsx b/tests/layouts/MobileNav.test.jsx
new file mode 100644
index 000000000000..6dc18ba06808
--- /dev/null
+++ b/tests/layouts/MobileNav.test.jsx
@@ -0,0 +1,77 @@
+import React from 'react'
+import { describe, it, expect, vi } from 'vitest'
+import { act } from '@testing-library/react'
+import { renderWithProviders, settingsWith } from '../test-utils'
+
+vi.mock('next/navigation', () => ({
+ usePathname: () => '/',
+ useRouter: () => ({ push: vi.fn() }),
+ useSearchParams: () => new URLSearchParams(''),
+}))
+
+const idle = vi.hoisted(() => ({
+ isSuccess: false,
+ isFetching: false,
+ isPending: false,
+ isError: false,
+ data: undefined,
+ mutate: () => {},
+ reset: () => {},
+ refetch: () => {},
+}))
+vi.mock('../../src/api/ApiCall', () => ({
+ ApiGetCall: () => idle,
+ ApiPostCall: () => idle,
+ ApiGetCallWithPagination: () => ({ ...idle, fetchNextPage: () => {} }),
+}))
+
+import { MobileNav } from '../../src/layouts/mobile-nav'
+
+const items = [{ title: 'Dashboard', path: '/' }]
+
+// MUI binds touchstart/touchmove/touchend on the document, so the swipe lifecycle is driven
+// with native events; userEvent emits pointer/mouse, which SwipeableDrawer ignores.
+const touch = (el, type, x = 5, y = 200) => {
+ const event = new Event(type, { bubbles: true, cancelable: true })
+ const point = { pageX: x, pageY: y, clientX: x, clientY: y }
+ Object.defineProperty(event, 'touches', { value: type === 'touchend' ? [] : [point] })
+ Object.defineProperty(event, 'changedTouches', { value: [point] })
+ act(() => {
+ el.dispatchEvent(event)
+ })
+}
+
+const renderNav = (props = {}) => {
+ const onOpen = vi.fn()
+ const onClose = vi.fn()
+ renderWithProviders(
+ ,
+ { settings: settingsWith({ bookmarkSidebar: false }) }
+ )
+ return { onOpen, onClose }
+}
+
+describe('MobileNav', () => {
+ it('renders no edge swipe area', () => {
+ renderNav()
+ expect(document.querySelector('.PrivateSwipeArea-root')).toBeNull()
+ })
+
+ // MUI forces the modal open while a swipe is in progress (maybeSwiping), and a touch with no
+ // movement never sets isSwiping, so handleBodyTouchEnd bails before onOpen/onClose. The drawer
+ // animates in and straight back out with the app's open state untouched.
+ it('leaves the drawer closed on a left-edge tap', () => {
+ const { onOpen, onClose } = renderNav()
+ const target = document.querySelector('.PrivateSwipeArea-root') ?? document.body
+ const drawer = document.querySelector('.MuiDrawer-root')
+ expect(drawer.getAttribute('aria-hidden')).toBe('true')
+
+ touch(target, 'touchstart')
+ expect(drawer.getAttribute('aria-hidden')).toBe('true')
+
+ touch(target, 'touchend')
+ expect(drawer.getAttribute('aria-hidden')).toBe('true')
+ expect(onOpen).not.toHaveBeenCalled()
+ expect(onClose).not.toHaveBeenCalled()
+ })
+})
diff --git a/tests/layouts/TabbedLayout.test.jsx b/tests/layouts/TabbedLayout.test.jsx
new file mode 100644
index 000000000000..f913a511e505
--- /dev/null
+++ b/tests/layouts/TabbedLayout.test.jsx
@@ -0,0 +1,261 @@
+import React from "react";
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { Button } from "@mui/material";
+import { renderWithProviders } from "../test-utils";
+
+// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook
+const layoutState = vi.hoisted(() => ({ isMobile: false, viewMode: "table" }));
+// partial mock: real module spread first, so new exports keep working here
+vi.mock("../../src/hooks/use-breakpoint", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useIsMobileLayout: () => layoutState.isMobile,
+ useIsTabletLayout: () => false,
+ useTableViewMode: () => layoutState.viewMode,
+}));
+
+const routerState = vi.hoisted(() => ({ push: vi.fn(), pathname: "/dashboardv2" }));
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: routerState.push }),
+ usePathname: () => routerState.pathname,
+ useSearchParams: () => new URLSearchParams(""),
+}));
+
+// Stable identities: a fresh object per call re-renders forever (tests/mocks/api-call.js)
+const idle = vi.hoisted(() => ({
+ isSuccess: false,
+ isFetching: false,
+ isPending: false,
+ isError: false,
+ data: undefined,
+ mutate: () => {},
+ reset: () => {},
+ refetch: () => {},
+}));
+vi.mock("../../src/api/ApiCall", () => ({
+ ApiGetCall: () => idle,
+ ApiPostCall: () => idle,
+ ApiGetCallWithPagination: () => ({ ...idle, fetchNextPage: () => {} }),
+}));
+
+import { TabbedLayout } from "../../src/layouts/TabbedLayout";
+import { CippPageActionsFab } from "../../src/components/CippComponents/CippPageActionsFab";
+import { CippDataTable } from "../../src/components/CippTable/CippDataTable";
+
+const tabOptions = [
+ { label: "Overview", path: "/dashboardv2", icon: "Dashboard" },
+ { label: "Identity", path: "/dashboardv2/identity", icon: "Person" },
+ { label: "Devices", path: "/dashboardv2/devices", icon: "Devices" },
+];
+
+const picker = () => screen.getByRole("button", { name: /switch view/i });
+const queryPickers = () => screen.queryAllByRole("button", { name: /switch view/i });
+
+// The trigger names the current view and so does its row in the sheet — scope sheet
+// assertions to the sheet, or every current-tab query matches twice.
+const openPicker = async (user) => {
+ await user.click(picker());
+ const sheet = await screen.findByText("Views");
+ return within(sheet.closest(".MuiDrawer-paper"));
+};
+
+describe("TabbedLayout", () => {
+ beforeEach(() => {
+ layoutState.isMobile = false;
+ layoutState.viewMode = "table";
+ routerState.push = vi.fn();
+ routerState.pathname = "/dashboardv2";
+ });
+
+ it("renders a tab bar on desktop and no picker", () => {
+ renderWithProviders(
+
+ page content
+
+ );
+
+ expect(screen.getByRole("tab", { name: /Overview/ })).toBeInTheDocument();
+ expect(screen.getByRole("tab", { name: /Devices/ })).toBeInTheDocument();
+ expect(queryPickers()).toHaveLength(0);
+ });
+
+ it("replaces the tab bar with a picker in the content flow on mobile", async () => {
+ layoutState.isMobile = true;
+ const user = userEvent.setup();
+ renderWithProviders(
+
+ page content
+
+ );
+
+ expect(screen.queryByRole("tab")).not.toBeInTheDocument();
+ // the trigger names where you are; the sheet is where the rest live
+ expect(picker()).toHaveAccessibleName("Overview switch view");
+
+ const sheet = await openPicker(user);
+ tabOptions.forEach((tab) => expect(sheet.getByText(tab.label)).toBeInTheDocument());
+ });
+
+ // pages/index.js re-exports the dashboard, so it renders at "/" while every tab path is
+ // /dashboardv2/... — no match meant the trigger fell back to "Views" and the sheet had no
+ // check. An aliased route belongs to the tab whose page it re-exports: the first one.
+ it("treats an aliased route as the first tab instead of showing no selection", async () => {
+ layoutState.isMobile = true;
+ routerState.pathname = "/";
+ const user = userEvent.setup();
+ renderWithProviders(
+
+ page content
+
+ );
+
+ expect(picker()).toHaveAccessibleName("Overview switch view");
+
+ const sheet = await openPicker(user);
+ expect(sheet.getByText("Overview").closest('[role="button"]')).toHaveClass("Mui-selected");
+
+ // and tapping the aliased tab is still a no-op, not a navigation loop
+ await user.click(sheet.getByText("Overview"));
+ expect(routerState.push).not.toHaveBeenCalled();
+ });
+
+ it("navigates when a tab row is tapped, and does nothing for the current tab", async () => {
+ layoutState.isMobile = true;
+ const user = userEvent.setup();
+ renderWithProviders(
+
+ page content
+
+ );
+
+ let sheet = await openPicker(user);
+ await user.click(sheet.getByText("Devices"));
+ expect(routerState.push).toHaveBeenCalledWith("/dashboardv2/devices");
+
+ routerState.push = vi.fn();
+ sheet = await openPicker(user);
+ await user.click(sheet.getByText("Overview"));
+ expect(routerState.push).not.toHaveBeenCalled();
+ });
+
+ // A single destination is not navigation — View Group and View Device have one tab each and
+ // used to get a FAB whose sheet offered the page you were already on.
+ it("renders no picker when there is only one destination", () => {
+ layoutState.isMobile = true;
+ renderWithProviders(
+
+ page content
+
+ );
+
+ expect(queryPickers()).toHaveLength(0);
+ });
+
+ it("counts visible tabs, not configured ones, when deciding to render", async () => {
+ layoutState.isMobile = true;
+ const user = userEvent.setup();
+ const gated = [tabOptions[0], { label: "Diagnostics", path: "/x", advanced: true }];
+
+ // one real tab plus one the user's advanced setting hides — nothing to switch between
+ const { unmount } = renderWithProviders(
+
+ page content
+
+ );
+ expect(queryPickers()).toHaveLength(0);
+ unmount();
+
+ renderWithProviders(
+
+ page content
+
+ );
+ const sheet = await openPicker(user);
+ expect(sheet.getByText("Overview")).toBeInTheDocument();
+ expect(sheet.queryByText("Diagnostics")).not.toBeInTheDocument();
+ });
+
+ // One control, one place, on every tabbed page — never annexing a heading that happens to
+ // be nearby on some page types and not others.
+ it("draws exactly one picker, in its own row, whatever the page renders", async () => {
+ layoutState.isMobile = true;
+ layoutState.viewMode = "cards";
+ renderWithProviders(
+
+
+
+ );
+
+ await waitFor(() => expect(screen.getByText("Relationships")).toBeInTheDocument());
+ expect(queryPickers()).toHaveLength(1);
+ // the page's own heading is still a heading, not a control
+ expect(picker()).not.toHaveTextContent("Relationships");
+ });
+
+ // Destinations used to ride in this sheet. A FAB is for a screen's primary action.
+ it("no longer puts destinations in the page FAB", async () => {
+ layoutState.isMobile = true;
+ const user = userEvent.setup();
+ renderWithProviders(
+
+
+ Add Variable
+
+
+ );
+
+ // the layout adds no FAB of its own any more — this one is the page's, and navigation
+ // sits in the content flow beside it
+ const fabs = screen.getAllByRole("button", { name: /Page actions/ });
+ expect(fabs).toHaveLength(1);
+ expect(picker()).toBeInTheDocument();
+
+ // the sheet is a modal, so it aria-hides the page behind it — assert on its contents only
+ await user.click(fabs[0]);
+ expect(await screen.findByRole("button", { name: "Add Variable" })).toBeInTheDocument();
+ expect(screen.queryByText("Identity")).not.toBeInTheDocument();
+ expect(screen.queryByText("Devices")).not.toBeInTheDocument();
+ expect(screen.queryByText("Views")).not.toBeInTheDocument();
+ });
+
+ // The defect the FAB placement caused: the card list claimed the corner during select mode
+ // but drew no FAB there, and the layout stood down because the corner was claimed — leaving
+ // no way at all to reach the other views until selection ended.
+ it("keeps navigation reachable while a card list is in select mode", async () => {
+ layoutState.isMobile = true;
+ layoutState.viewMode = "cards";
+ const user = userEvent.setup();
+ renderWithProviders(
+
+
+
+ );
+
+ await waitFor(() => expect(queryPickers()).toHaveLength(1));
+
+ await user.click(screen.getByRole("button", { name: /^Select$/ }));
+ await waitFor(() =>
+ expect(screen.getByRole("button", { name: /^Cancel$/ })).toBeInTheDocument()
+ );
+
+ // this is the assertion the FAB placement could not satisfy
+ expect(queryPickers()).toHaveLength(1);
+ const sheet = await openPicker(user);
+ expect(sheet.getByText("Devices")).toBeInTheDocument();
+ });
+});
diff --git a/tests/layouts/header-overflow.stories.jsx b/tests/layouts/header-overflow.stories.jsx
new file mode 100644
index 000000000000..3ee8d203204b
--- /dev/null
+++ b/tests/layouts/header-overflow.stories.jsx
@@ -0,0 +1,90 @@
+import React from 'react'
+import { within, waitFor, expect } from 'storybook/test'
+import { Box, Stack, SvgIcon, Typography } from '@mui/material'
+import { Mail, Fingerprint, CalendarToday } from '@mui/icons-material'
+import { CippCopyToClipBoard } from '../../src/components/CippComponents/CippCopyToClipboard'
+import { shrinkToPhoneViewport } from '../viewport'
+
+/**
+ * Reproduces HeaderedTabbedLayout's mobile header markup — it cannot render the layout
+ * itself, which needs next/router and this Storybook runs on @storybook/react-vite. Keep the
+ * two in step: this exists to hold the CSS contract that lets a copy-chip truncate.
+ *
+ * A guest UPN is the worst case in the app: `user_domain.onmicrosoft.com#EXT#@tenant...` is
+ * one unbreakable token, roughly 60 characters, and it ran off the right edge of the screen.
+ */
+const GUEST_UPN = 'jduprey_7ngn50.onmicrosoft.com#EXT#@1h81wz.onmicrosoft.com'
+
+const SubtitleItem = ({ icon, children }) => (
+
+
+ {icon}
+
+
+ {children}
+
+
+)
+
+export default {
+ title: 'Layouts/HeaderedTabbedLayout/MobileHeader',
+ tags: ['autodocs'],
+}
+
+export const GuestUpnDoesNotSpill = {
+ render: () => (
+
+
+
+
+
+ jduprey
+
+
+
+
+ }>
+
+
+ }>
+
+
+ }>Created: 1 month ago
+
+
+
+ ),
+ play: async ({ canvasElement, step }) => {
+ const onAPhone = await shrinkToPhoneViewport()
+ if (!onAPhone) return
+ const canvas = within(canvasElement)
+
+ await step('the guest UPN chip truncates instead of widening the page', async () => {
+ const host = canvasElement.querySelector('[data-testid="header-host"]')
+ await waitFor(() => expect(host.scrollWidth).toBeLessThanOrEqual(host.clientWidth))
+ await expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(
+ document.documentElement.clientWidth
+ )
+ })
+
+ await step('and it is still the full value on the clipboard, not a truncated one', async () => {
+ // the label is elided in CSS only — the text node keeps the whole UPN
+ await expect(canvas.getByText(GUEST_UPN)).toBeInTheDocument()
+ })
+ },
+}
diff --git a/tests/layouts/notification-badge.stories.jsx b/tests/layouts/notification-badge.stories.jsx
new file mode 100644
index 000000000000..5287412947fb
--- /dev/null
+++ b/tests/layouts/notification-badge.stories.jsx
@@ -0,0 +1,92 @@
+import React from 'react'
+import { within, waitFor, expect } from 'storybook/test'
+import { Avatar, Badge, IconButton, Stack, SvgIcon } from '@mui/material'
+import BellIcon from '@heroicons/react/24/outline/BellIcon'
+import { shrinkToPhoneViewport, growToDesktopViewport } from '../viewport'
+
+/**
+ * The top bar's right-hand cluster, reproduced — `TopNav` itself pulls in the router, the
+ * tenant list and half a dozen API hooks. Keep this in step with `notifications-popover.js`
+ * and `top-nav.js`; it exists to hold one thing, which is that the notification dot belongs
+ * to the bell and not to the avatar beside it.
+ */
+const Cluster = ({ mobile }) => (
+
+
+
+
+
+
+
+
+
+ J
+
+
+)
+
+export default {
+ title: 'Layouts/TopNav/NotificationBadge',
+ tags: ['autodocs'],
+}
+
+const dotAndAvatar = (canvasElement) => ({
+ bell: canvasElement.querySelector('.MuiBadge-root'),
+ dot: canvasElement.querySelector('.MuiBadge-badge'),
+ avatar: canvasElement.querySelector('[data-testid="account-avatar"]'),
+})
+
+export const DotStaysWithTheBellOnAPhone = {
+ render: () => ,
+ play: async ({ canvasElement, step }) => {
+ const onAPhone = await shrinkToPhoneViewport()
+ if (!onAPhone) return
+ const { bell, dot, avatar } = dotAndAvatar(canvasElement)
+
+ await step('the dot sits inside the bell, not over the gap to the avatar', async () => {
+ await waitFor(() => {
+ const d = dot.getBoundingClientRect()
+ const b = bell.getBoundingClientRect()
+ const a = avatar.getBoundingClientRect()
+ expect(d.right).toBeLessThanOrEqual(b.right + 0.5)
+ expect(d.top).toBeGreaterThanOrEqual(b.top - 0.5)
+ // and there is real space left between it and the avatar
+ expect(a.left - d.right).toBeGreaterThan(4)
+ })
+ })
+ },
+}
+
+// The md values are MUI's own, so the badge keeps hanging off the corner above the breakpoint.
+export const DotKeepsItsCornerOnDesktop = {
+ render: () => ,
+ play: async ({ canvasElement, step }) => {
+ const onDesktop = await growToDesktopViewport()
+ if (!onDesktop) return
+ const { bell, dot } = dotAndAvatar(canvasElement)
+
+ await step('the dot still overhangs the button', async () => {
+ await waitFor(() => {
+ const d = dot.getBoundingClientRect()
+ const b = bell.getBoundingClientRect()
+ expect(d.right).toBeGreaterThan(b.right)
+ })
+ })
+ },
+}
diff --git a/tests/lint/mobile-layout-patterns.test.js b/tests/lint/mobile-layout-patterns.test.js
new file mode 100644
index 000000000000..30fe14b26f3e
--- /dev/null
+++ b/tests/lint/mobile-layout-patterns.test.js
@@ -0,0 +1,253 @@
+import { describe, it, expect } from "vitest";
+import fs from "node:fs";
+import path from "node:path";
+
+// Two MUI patterns account for nearly every mobile layout bug in this app, and both are
+// invisible on a desktop screen — so they ship freely and only surface as a phone report.
+// This walks src/ and fails on either, which is cheaper than finding them one at a time.
+//
+// 1. / size={{ xs: N }} with N < 12 holds a desktop column split at 390px.
+// 2. A Stack with flexWrap but no useFlexGap: MUI's `spacing` is a margin-left between
+// children, and every wrapped row inherits it, so each new line starts indented.
+// 3. A dashboard card pinned to a pixel height. That height exists to level two columns of
+// a desktop grid; below lg the grid is a single column, so it levels nothing and clips
+// instead — the Secure Score card lost its whole stats row off the bottom edge.
+
+const SRC = path.resolve(__dirname, "../../src");
+
+// Dead Devias template code — nothing in pages/, components/ or layouts/ imports it.
+const IGNORED_DIRS = new Set(["sections"]);
+
+const walk = (dir) =>
+ fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ return IGNORED_DIRS.has(entry.name) ? [] : walk(full);
+ }
+ return /\.(js|jsx)$/.test(entry.name) ? [full] : [];
+ });
+
+const rel = (file) => path.relative(SRC, file);
+
+// Commented-out JSX is not shipped markup — blank it (preserving newlines so reported
+// line numbers stay accurate) rather than flagging code nobody renders.
+const stripComments = (source) =>
+ source
+ .replace(/\{\s*\/\*[\s\S]*?\*\/\s*\}/g, (m) => m.replace(/[^\n]/g, " "))
+ .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "));
+
+/** Opening tags for `name`, brace-aware so multi-line JSX props stay in one string. */
+const openingTags = (source, name) => {
+ const tags = [];
+ const re = new RegExp(`<${name}\\b`, "g");
+ let match;
+ while ((match = re.exec(source))) {
+ let depth = 0;
+ for (let i = match.index; i < source.length; i += 1) {
+ const char = source[i];
+ if (char === "{") depth += 1;
+ else if (char === "}") depth -= 1;
+ else if (char === ">" && depth === 0) {
+ const text = source.slice(match.index, i + 1);
+ const line = source.slice(0, match.index).split("\n").length;
+ tags.push({ text, line, endLine: line + text.split("\n").length - 1 });
+ break;
+ }
+ }
+ }
+ return tags;
+};
+
+// Not every fixed split is a bug — a tile can be designed to sit two-up at 390px. Marking
+// the site opts it out, deliberately, in the source, next to the reason, where
+// `rg mobile-layout-ok` finds every one of them. Read from the RAW source because comments
+// are stripped before matching, and counted on the tag's own lines or the three above it,
+// since JSX has nowhere to put a comment between props.
+const MARKER = "mobile-layout-ok";
+const LOOKBACK = 3;
+
+const isExempt = (marked, tag) => {
+ for (let line = tag.line - LOOKBACK; line <= tag.endLine; line += 1) {
+ if (marked.has(line)) return true;
+ }
+ return false;
+};
+
+/** Grid splits that survive a phone, as `line reason` strings. */
+export const gridOffenders = (rawSource) => {
+ const source = stripComments(rawSource);
+ const marked = new Set();
+ rawSource.split("\n").forEach((text, index) => {
+ if (text.includes(MARKER)) marked.add(index + 1);
+ });
+
+ const offenders = [];
+ for (const tag of openingTags(source, "Grid")) {
+ if (isExempt(marked, tag)) continue;
+ const bare = tag.text.match(/\bsize=\{(\d+(?:\.\d+)?)\}/);
+ if (bare && Number(bare[1]) !== 12) {
+ offenders.push(`${tag.line} size={${bare[1]}}`);
+ }
+ const xs = tag.text.match(/\bsize=\{\{[^}]*?\bxs:\s*(\d+(?:\.\d+)?)/);
+ if (xs && Number(xs[1]) < 12) {
+ offenders.push(`${tag.line} xs: ${xs[1]}`);
+ }
+ // v1 props are silently inert under Grid v2 — the split never applied at all
+ if (/]*\bxs=\{/.test(tag.text)) {
+ offenders.push(`${tag.line} legacy xs= prop (inert under Grid v2)`);
+ }
+ }
+ return offenders;
+};
+
+/**
+ * Percent-width column splits on Box/Stack, as `line reason` strings. The flexbox sibling
+ * of the Grid rule above: `` beside `` holds a desktop
+ * split at 390px too — the role editor's summary pane sat off the right edge of a phone
+ * this way. A responsive object (`width={{ xs: "100%", xl: "30%" }}`) passes.
+ */
+export const percentSplitOffenders = (rawSource) => {
+ const source = stripComments(rawSource);
+ const marked = new Set();
+ rawSource.split("\n").forEach((text, index) => {
+ if (text.includes(MARKER)) marked.add(index + 1);
+ });
+
+ const offenders = [];
+ for (const name of ["Box", "Stack"]) {
+ for (const tag of openingTags(source, name)) {
+ if (isExempt(marked, tag)) continue;
+ const percent = tag.text.match(/\bwidth=\{?"(\d{1,2})%"\}?/);
+ if (percent) offenders.push(`${tag.line} width="${percent[1]}%"`);
+ }
+ }
+ return offenders;
+};
+
+/** Dashboard card wrappers pinned to a pixel height, as `line reason` strings. */
+export const pinnedHeightOffenders = (rawSource) => {
+ const source = stripComments(rawSource);
+ const marked = new Set();
+ rawSource.split("\n").forEach((text, index) => {
+ if (text.includes(MARKER)) marked.add(index + 1);
+ });
+
+ const offenders = [];
+ for (const tag of openingTags(source, "Box")) {
+ if (isExempt(marked, tag)) continue;
+ // `height: 450` — a bare number. `height: { xs: 'auto', lg: 450 }` is the fix, and
+ // minHeight/maxHeight are constraints rather than a pin, so both are left alone.
+ const pinned = tag.text.match(/[^a-zA-Z]height:\s*(\d+)\s*[,}]/);
+ if (pinned) offenders.push(`${tag.line} height: ${pinned[1]}`);
+ }
+ return offenders;
+};
+
+const files = walk(SRC);
+const dashboardFiles = files.filter((file) => rel(file).startsWith(path.join("pages", "dashboardv2")));
+
+describe("mobile layout patterns", () => {
+ it("has files to check", () => {
+ expect(files.length).toBeGreaterThan(100);
+ });
+
+ it("declares no Grid column split that survives a phone", () => {
+ const offenders = files.flatMap((file) =>
+ gridOffenders(fs.readFileSync(file, "utf8")).map((offender) => `${rel(file)}:${offender}`)
+ );
+ expect(offenders, `Use size={{ xs: 12, sm|md: N }} instead:\n${offenders.join("\n")}`).toEqual(
+ []
+ );
+ });
+
+ it("takes a marked split at its word", () => {
+ const split = " \n";
+ expect(gridOffenders(split)).toEqual(["1 xs: 6"]);
+ // on a line above, which is the only place JSX leaves room for one
+ expect(gridOffenders(` // two-up by design: ${MARKER}\n${split}`)).toEqual([]);
+ // or among the props of a tag spanning several lines
+ expect(
+ gridOffenders(` \n`)
+ ).toEqual([]);
+ // but a marker further up the file does not blanket the rest of it
+ expect(gridOffenders(` // ${MARKER}\n\n\n\n\n${split}`)).toEqual(["6 xs: 6"]);
+ });
+
+ it("declares no percent-width flex split that survives a phone", () => {
+ const offenders = files.flatMap((file) =>
+ percentSplitOffenders(fs.readFileSync(file, "utf8")).map(
+ (offender) => `${rel(file)}:${offender}`
+ )
+ );
+ expect(
+ offenders,
+ `A percent width on Box/Stack holds a desktop split at 390px. Use width={{ xs: "100%", md|xl: "N%" }} or a Grid:\n${offenders.join("\n")}`
+ ).toEqual([]);
+ });
+
+ it("reads a percent split only as a fixed string width", () => {
+ expect(percentSplitOffenders(` \n`)).toEqual(['1 width="30%"']);
+ expect(percentSplitOffenders(` \n`)).toEqual(['1 width="80%"']);
+ expect(percentSplitOffenders(` \n`)).toEqual([]);
+ expect(percentSplitOffenders(` \n`)).toEqual([]);
+ expect(percentSplitOffenders(` \n`)).toEqual([]);
+ expect(percentSplitOffenders(` // ${MARKER}\n \n`)).toEqual([]);
+ });
+
+ it("pins no dashboard card to a pixel height", () => {
+ expect(dashboardFiles.length).toBeGreaterThan(0);
+ const offenders = dashboardFiles.flatMap((file) =>
+ pinnedHeightOffenders(fs.readFileSync(file, "utf8")).map(
+ (offender) => `${rel(file)}:${offender}`
+ )
+ );
+ expect(
+ offenders,
+ `Below lg the dashboard is one column, so a fixed height only clips. Use height: { xs: 'auto', lg: N }:\n${offenders.join("\n")}`
+ ).toEqual([]);
+ });
+
+ it("reads a pinned height only as a bare number", () => {
+ expect(pinnedHeightOffenders(` \n`)).toEqual(["1 height: 450"]);
+ expect(pinnedHeightOffenders(` \n`)).toEqual([]);
+ expect(pinnedHeightOffenders(` \n`)).toEqual([]);
+ expect(pinnedHeightOffenders(` \n`)).toEqual([]);
+ expect(pinnedHeightOffenders(` // ${MARKER}\n \n`)).toEqual([]);
+ });
+
+ // Side nav, the drawer that replaces it, the hamburger that opens the drawer and the content
+ // gutter are four gates on one decision. Any of them declaring its own query lets them
+ // disagree, and a width with no side nav and no way to open the drawer has no nav at all.
+ it("keys layout chrome off the shared breakpoint hook, not its own media query", () => {
+ const offenders = [];
+ for (const name of ["index.js", "top-nav.js"]) {
+ const source = stripComments(fs.readFileSync(path.join(SRC, "layouts", name), "utf8"));
+ source.split("\n").forEach((line, i) => {
+ if (/useMediaQuery\(.*breakpoints\.(down|up|between)\(/.test(line)) {
+ offenders.push(`layouts/${name}:${i + 1}`);
+ }
+ });
+ }
+ expect(
+ offenders,
+ `Nav gates have to agree. Use useIsMobileLayout from hooks/use-breakpoint:\n${offenders.join("\n")}`
+ ).toEqual([]);
+ });
+
+ it("gives every wrapping Stack useFlexGap", () => {
+ const offenders = [];
+ for (const file of files) {
+ const source = stripComments(fs.readFileSync(file, "utf8"));
+ if (!source.includes("flexWrap")) continue;
+ for (const { text, line } of openingTags(source, "Stack")) {
+ if (!text.includes("flexWrap") || text.includes("useFlexGap")) continue;
+ if (/flexWrap[=:]\s*[{'"\s]*nowrap/.test(text)) continue;
+ offenders.push(`${rel(file)}:${line}`);
+ }
+ }
+ expect(
+ offenders,
+ `Stack spacing is a margin that wrapped rows inherit — add useFlexGap:\n${offenders.join("\n")}`
+ ).toEqual([]);
+ });
+});
diff --git a/tests/mocks/api-call.js b/tests/mocks/api-call.js
index 65c406fd0890..fe53d865b35e 100644
--- a/tests/mocks/api-call.js
+++ b/tests/mocks/api-call.js
@@ -31,6 +31,7 @@ export const paginatedResult = (rows = [], overrides = {}) => ({
export const postResult = (overrides = {}) => ({
mutate: vi.fn(),
+ reset: vi.fn(),
isPending: false,
isSuccess: false,
isError: false,
diff --git a/tests/mocks/baseline-tenant-fixture.json b/tests/mocks/baseline-tenant-fixture.json
new file mode 100644
index 000000000000..07857a1799b2
--- /dev/null
+++ b/tests/mocks/baseline-tenant-fixture.json
@@ -0,0 +1,613 @@
+{
+ "baseline": {
+ "GUID": "3ca8c0ec-9294-4060-8191-f1f2f3af37da",
+ "templateName": ".Baseline - Tenant",
+ "baselineName": ".Baseline - Tenant",
+ "description": "description
",
+ "assignedTenants": [
+ "Exported Template"
+ ],
+ "assignments": {
+ "label": "Exported Template",
+ "value": "Exported Template",
+ "type": "Tenant"
+ },
+ "exclusions": null,
+ "excludedTenants": [],
+ "alertEmails": "",
+ "alertWebhookUrl": "",
+ "disableScheduledRuns": false,
+ "standardsCount": 9,
+ "stageNames": [
+ "Default"
+ ],
+ "stages": [
+ {
+ "name": "Default",
+ "logic": "and",
+ "conditions": [],
+ "standards": [
+ "ActivityBasedTimeout",
+ "AnonReportDisable",
+ "AuditLog",
+ "DisableBasicAuthSMTP",
+ "DisableGuestDirectory",
+ "EnablePronouns",
+ "FormsPhishingProtection",
+ "MailContacts",
+ "PhishProtection"
+ ],
+ "standardsConfig": [
+ {
+ "standard": "ActivityBasedTimeout",
+ "instance": "ActivityBasedTimeout",
+ "variables": {
+ "timeout": "06:00:00"
+ },
+ "remediateEnabled": false,
+ "alertEnabled": false,
+ "alertOnRemediate": false
+ },
+ {
+ "standard": "AnonReportDisable",
+ "instance": "AnonReportDisable",
+ "variables": null,
+ "remediateEnabled": false,
+ "alertEnabled": false,
+ "alertOnRemediate": false
+ },
+ {
+ "standard": "AuditLog",
+ "instance": "AuditLog",
+ "variables": null,
+ "remediateEnabled": false,
+ "alertEnabled": false,
+ "alertOnRemediate": false
+ },
+ {
+ "standard": "DisableBasicAuthSMTP",
+ "instance": "DisableBasicAuthSMTP",
+ "variables": null,
+ "remediateEnabled": false,
+ "alertEnabled": false,
+ "alertOnRemediate": false
+ },
+ {
+ "standard": "DisableGuestDirectory",
+ "instance": "DisableGuestDirectory",
+ "variables": null,
+ "remediateEnabled": false,
+ "alertEnabled": false,
+ "alertOnRemediate": false
+ },
+ {
+ "standard": "EnablePronouns",
+ "instance": "EnablePronouns",
+ "variables": null,
+ "remediateEnabled": false,
+ "alertEnabled": false,
+ "alertOnRemediate": false
+ },
+ {
+ "standard": "FormsPhishingProtection",
+ "instance": "FormsPhishingProtection",
+ "variables": null,
+ "remediateEnabled": false,
+ "alertEnabled": false,
+ "alertOnRemediate": false
+ },
+ {
+ "standard": "MailContacts",
+ "instance": "MailContacts",
+ "variables": {
+ "SecurityContact": "support@bezalu.com",
+ "TechContact": "support@bezalu.com",
+ "GeneralContact": "support@bezalu.com",
+ "MarketingContact": ""
+ },
+ "remediateEnabled": false,
+ "alertEnabled": false,
+ "alertOnRemediate": false
+ },
+ {
+ "standard": "PhishProtection",
+ "instance": "PhishProtection",
+ "variables": null,
+ "remediateEnabled": false,
+ "alertEnabled": false,
+ "alertOnRemediate": false
+ }
+ ]
+ }
+ ],
+ "remediationPosture": "Report",
+ "updatedAt": 1787415499,
+ "updatedBy": "developer@localhost",
+ "occupancy": [
+ {
+ "stage": 1,
+ "name": "Default",
+ "standardsCount": 9,
+ "tenants": [
+ null
+ ],
+ "nextAdvanceAt": null
+ }
+ ],
+ "tenantStates": []
+ },
+ "definitions": [
+ {
+ "name": "DisableBasicAuthSMTP",
+ "label": "Disable SMTP Basic Authentication",
+ "cat": "Exchange Standards",
+ "tag": [
+ "CIS M365 7.0.0 (6.5.4)",
+ "NIST CSF 2.0 (PR.IR-01)"
+ ],
+ "impact": "Medium Impact",
+ "helpText": "Disables SMTP AUTH organization-wide, impacting POP and IMAP clients that rely on SMTP for sending emails. Default for new tenants. For more information, see the [Microsoft documentation](https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/authenticated-client-smtp-submission)",
+ "executiveText": "Disables outdated email authentication methods that are vulnerable to security attacks, forcing applications and devices to use modern, more secure authentication protocols. This reduces the risk of email-based security breaches and credential theft.",
+ "docsDescription": "Disables tenant-wide SMTP basic authentication, including for all explicitly enabled users, impacting POP and IMAP clients that rely on SMTP for sending emails. For more information, see the [Microsoft documentation](https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/authenticated-client-smtp-submission).",
+ "impactColour": "warning",
+ "addedDate": "2021-11-16",
+ "powershellEquivalent": "Set-TransportConfig -SmtpClientAuthenticationDisabled $true",
+ "appliesToTest": [
+ "CISAMSEXO51",
+ "CIS_6_5_4",
+ "ZTNA21799"
+ ],
+ "recommendedBy": [
+ "CIS",
+ "CIPP"
+ ],
+ "requiredCapabilities": [
+ "EXCHANGE_S_STANDARD",
+ "EXCHANGE_S_ENTERPRISE",
+ "EXCHANGE_S_STANDARD_GOV",
+ "EXCHANGE_S_ENTERPRISE_GOV",
+ "EXCHANGE_LITE"
+ ],
+ "secureScoreImpact": 10,
+ "compare": "subset",
+ "variables": {
+ "disabled": {
+ "type": "switch",
+ "label": "SMTP basic authentication disabled",
+ "default": true,
+ "recommended": true
+ }
+ },
+ "expected": {
+ "SmtpClientAuthenticationDisabled": "%disabled%",
+ "UsersWithSmtpAuthEnabled": []
+ },
+ "read": {
+ "cacheType": "ExoTransportConfig"
+ },
+ "prepare": "Get-CIPPBaselineDisableBasicAuthSMTPState",
+ "remediate": {
+ "executor": "DisableBasicAuthSMTP",
+ "disabled": "%disabled%"
+ }
+ },
+ {
+ "name": "ActivityBasedTimeout",
+ "label": "Enable Activity based Timeout",
+ "cat": "Global Standards",
+ "tag": [
+ "CIS M365 7.0.0 (1.3.2)",
+ "spo_idle_session_timeout",
+ "NIST CSF 2.0 (PR.AA-03)"
+ ],
+ "impact": "Medium Impact",
+ "helpText": "Enables and sets Idle session timeout for Microsoft 365 to 1 hour. This policy affects most M365 web apps",
+ "executiveText": "Automatically logs out inactive users from Microsoft 365 applications after a specified time period to prevent unauthorized access to company data on unattended devices. This security measure protects against data breaches when employees leave workstations unlocked.",
+ "impactColour": "warning",
+ "addedDate": "2022-04-13",
+ "powershellEquivalent": "Portal or Graph API",
+ "appliesToTest": [
+ "CIS_1_3_2",
+ "ZTNA21813",
+ "ZTNA21814",
+ "ZTNA21815"
+ ],
+ "recommendedBy": [
+ "CIS"
+ ],
+ "requiredCapabilities": [],
+ "secureScoreImpact": 5,
+ "compare": "subset",
+ "variables": {
+ "timeout": {
+ "type": "autoComplete",
+ "label": "Idle session timeout",
+ "options": [
+ {
+ "label": "1 Hour",
+ "value": "01:00:00"
+ },
+ {
+ "label": "3 Hours",
+ "value": "03:00:00"
+ },
+ {
+ "label": "6 Hours",
+ "value": "06:00:00"
+ },
+ {
+ "label": "12 Hours",
+ "value": "12:00:00"
+ },
+ {
+ "label": "24 Hours",
+ "value": "1.00:00:00"
+ }
+ ],
+ "default": "01:00:00",
+ "recommended": "01:00:00"
+ }
+ },
+ "expected": {
+ "timeout": "%timeout%"
+ },
+ "read": {
+ "cacheType": "ActivityBasedTimeoutPolicy"
+ },
+ "prepare": "Get-CIPPBaselineActivityBasedTimeoutState",
+ "remediate": {
+ "executor": "ActivityBasedTimeout",
+ "timeout": "%timeout%"
+ }
+ },
+ {
+ "name": "AnonReportDisable",
+ "label": "Enable Usernames instead of pseudo anonymised names in reports",
+ "cat": "Global Standards",
+ "tag": [],
+ "impact": "Low Impact",
+ "helpText": "Shows usernames instead of pseudo anonymised names in reports. This standard is required for reporting to work correctly.",
+ "executiveText": "Configures Microsoft 365 reports to display actual usernames instead of anonymized identifiers, enabling IT administrators to effectively troubleshoot issues and generate meaningful usage reports. This improves operational efficiency and system management capabilities.",
+ "docsDescription": "Microsoft announced some APIs and reports no longer return names, to comply with compliance and legal requirements in specific countries. This proves an issue for a lot of MSPs because those reports are often helpful for engineers. This standard applies a setting that shows usernames in those API calls / reports.",
+ "impactColour": "info",
+ "addedDate": "2021-11-16",
+ "powershellEquivalent": "Update-MgBetaAdminReportSetting -BodyParameter @{displayConcealedNames = $true}",
+ "recommendedBy": [
+ "CIPP"
+ ],
+ "requiredCapabilities": [],
+ "secureScoreImpact": 0,
+ "compare": "subset",
+ "variables": null,
+ "expected": {
+ "displayConcealedNames": false
+ },
+ "read": {
+ "cacheType": "AdminReportSettings"
+ },
+ "remediate": {
+ "executor": "GraphRequest",
+ "requests": [
+ {
+ "method": "PATCH",
+ "uri": "admin/reportSettings",
+ "body": {
+ "displayConcealedNames": false
+ }
+ }
+ ]
+ }
+ },
+ {
+ "name": "AuditLog",
+ "label": "Enable the Unified Audit Log",
+ "cat": "Global Standards",
+ "tag": [
+ "CIS M365 7.0.0 (3.1.1)",
+ "mip_search_auditlog",
+ "NIST CSF 2.0 (DE.CM-09)"
+ ],
+ "impact": "Low Impact",
+ "helpText": "Enables the Unified Audit Log for tracking and auditing activities. Also runs Enable-OrganizationCustomization if necessary.",
+ "executiveText": "Activates comprehensive activity logging across Microsoft 365 services to track user actions, system changes, and security events. This provides essential audit trails for compliance requirements, security investigations, and regulatory reporting.",
+ "impactColour": "info",
+ "addedDate": "2021-11-16",
+ "powershellEquivalent": "Enable-OrganizationCustomization",
+ "appliesToTest": [
+ "CISAMSEXO171",
+ "CISAMSEXO173",
+ "CIS_3_1_1"
+ ],
+ "recommendedBy": [
+ "CIS",
+ "CIPP"
+ ],
+ "requiredCapabilities": [
+ "EXCHANGE_S_STANDARD",
+ "EXCHANGE_S_ENTERPRISE",
+ "EXCHANGE_S_STANDARD_GOV",
+ "EXCHANGE_S_ENTERPRISE_GOV",
+ "EXCHANGE_LITE"
+ ],
+ "secureScoreImpact": 0,
+ "compare": "subset",
+ "variables": {
+ "enabled": {
+ "type": "switch",
+ "label": "Unified Audit Log ingestion enabled",
+ "default": true,
+ "recommended": true,
+ "locked": true
+ }
+ },
+ "expected": {
+ "UnifiedAuditLogIngestionEnabled": "%enabled%"
+ },
+ "read": {
+ "cacheType": "ExoAdminAuditLogConfig"
+ },
+ "remediate": {
+ "executor": "ExoRequest",
+ "cmdlets": [
+ {
+ "cmdlet": "Enable-OrganizationCustomization",
+ "params": null,
+ "continueOnError": true
+ },
+ {
+ "cmdlet": "Set-AdminAuditLogConfig",
+ "params": {
+ "UnifiedAuditLogIngestionEnabled": "%enabled%"
+ }
+ }
+ ]
+ }
+ },
+ {
+ "name": "DisableGuestDirectory",
+ "label": "Restrict guest user access to directory objects",
+ "cat": "Global Standards",
+ "tag": [
+ "CIS M365 7.0.0 (5.1.6.2)",
+ "CISA (MS.AAD.5.1v1)",
+ "EIDSCA.AP14",
+ "EIDSCA.ST08",
+ "EIDSCA.ST09",
+ "NIST CSF 2.0 (PR.AA-05)",
+ "SMB1001 (2.8)"
+ ],
+ "impact": "Low Impact",
+ "helpText": "Disables Guest access to enumerate directory objects. This prevents guest users from seeing other users or guests in the directory.",
+ "executiveText": "Restricts external guest users from viewing the company's employee directory and organizational structure, protecting sensitive information about staff and internal groups. This security measure prevents unauthorized access to corporate contact information while still allowing necessary collaboration.",
+ "docsDescription": "Sets it so guests can view only their own user profile. Permission to view other users isn't allowed. Also restricts guest users from seeing the membership of groups they're in. See exactly what get locked down in the [Microsoft documentation.](https://learn.microsoft.com/en-us/entra/fundamentals/users-default-permissions)",
+ "impactColour": "info",
+ "addedDate": "2022-05-04",
+ "powershellEquivalent": "Set-AzureADMSAuthorizationPolicy -GuestUserRoleId '2af84b1e-32c8-42b7-82bc-daa82404023b'",
+ "appliesToTest": [
+ "CIS_5_1_6_2",
+ "EIDSCAAP07",
+ "EIDSCAAP14",
+ "EIDSCAST08",
+ "EIDSCAST09",
+ "SMB1001_2_8",
+ "ZTNA21792"
+ ],
+ "recommendedBy": [
+ "CIPP"
+ ],
+ "requiredCapabilities": [],
+ "secureScoreImpact": 0,
+ "compare": "subset",
+ "variables": {
+ "guestUserRoleId": {
+ "type": "autoComplete",
+ "label": "Guest user access level",
+ "options": [
+ {
+ "label": "Restricted access (guests can only see their own profile)",
+ "value": "2af84b1e-32c8-42b7-82bc-daa82404023b"
+ },
+ {
+ "label": "Limited access (guests can see membership of non-hidden groups)",
+ "value": "10dae51f-b6af-4016-8d66-8c2a99b929b3"
+ },
+ {
+ "label": "Same access as member users",
+ "value": "a0b1b346-4d3e-4e8b-98f8-753987be4970"
+ }
+ ],
+ "default": "2af84b1e-32c8-42b7-82bc-daa82404023b",
+ "recommended": "2af84b1e-32c8-42b7-82bc-daa82404023b"
+ }
+ },
+ "expected": {
+ "guestUserRoleId": "%guestUserRoleId%"
+ },
+ "read": {
+ "cacheType": "AuthorizationPolicy"
+ },
+ "remediate": {
+ "executor": "GraphRequest",
+ "requests": [
+ {
+ "method": "PATCH",
+ "asApp": false,
+ "uri": "policies/authorizationPolicy/authorizationPolicy",
+ "body": {
+ "guestUserRoleId": "%guestUserRoleId%"
+ }
+ }
+ ]
+ }
+ },
+ {
+ "name": "EnablePronouns",
+ "label": "Enable Pronouns",
+ "cat": "Global Standards",
+ "tag": [],
+ "impact": "Low Impact",
+ "helpText": "Enables the Pronouns feature for the tenant. This allows users to set their pronouns in their profile.",
+ "executiveText": "Allows employees to display their preferred pronouns in their Microsoft 365 profiles, supporting inclusive workplace practices and helping colleagues communicate respectfully. This feature enhances diversity and inclusion initiatives while fostering a more welcoming work environment.",
+ "impactColour": "info",
+ "addedDate": "2024-06-05",
+ "powershellEquivalent": "Update-MgBetaAdminPeoplePronoun -IsEnabledInOrganization:$true",
+ "recommendedBy": [],
+ "requiredCapabilities": [],
+ "secureScoreImpact": 0,
+ "compare": "subset",
+ "variables": null,
+ "expected": {
+ "isEnabledInOrganization": true
+ },
+ "read": {
+ "cacheType": "Pronouns"
+ },
+ "remediate": {
+ "executor": "GraphRequest",
+ "requests": [
+ {
+ "method": "PATCH",
+ "uri": "admin/people/pronouns",
+ "body": {
+ "isEnabledInOrganization": true
+ }
+ }
+ ]
+ }
+ },
+ {
+ "name": "FormsPhishingProtection",
+ "label": "Enable internal phishing protection for Forms",
+ "cat": "Global Standards",
+ "tag": [
+ "CIS M365 7.0.0 (1.3.5)",
+ "Security",
+ "PhishingProtection"
+ ],
+ "impact": "Low Impact",
+ "helpText": "Enables internal phishing protection for Microsoft Forms to help prevent malicious forms from being created and shared within the organization. This feature scans forms created by internal users for potential phishing content and suspicious patterns.",
+ "executiveText": "Automatically scans Microsoft Forms created by employees for malicious content and phishing attempts, preventing the creation and distribution of harmful forms within the organization. This protects against both internal threats and compromised accounts that might be used to distribute malicious content.",
+ "docsDescription": "Enables internal phishing protection for Microsoft Forms by setting the isInOrgFormsPhishingScanEnabled property to true. This security feature helps protect organizations from internal phishing attacks through Microsoft Forms by automatically scanning forms created by internal users for potential malicious content, suspicious links, and phishing patterns. When enabled, Forms will analyze form content and block or flag potentially dangerous forms before they can be shared within the organization.",
+ "impactColour": "info",
+ "addedDate": "2025-06-06",
+ "powershellEquivalent": "Graph API",
+ "appliesToTest": [
+ "CIS_1_3_5"
+ ],
+ "recommendedBy": [
+ "CIS",
+ "CIPP"
+ ],
+ "requiredCapabilities": [],
+ "secureScoreImpact": 0,
+ "compare": "subset",
+ "variables": null,
+ "expected": {
+ "isInOrgFormsPhishingScanEnabled": true
+ },
+ "read": {
+ "cacheType": "FormsSettings"
+ },
+ "remediate": {
+ "executor": "GraphRequest",
+ "requests": [
+ {
+ "method": "PATCH",
+ "asApp": false,
+ "uri": "admin/forms/settings",
+ "body": {
+ "isInOrgFormsPhishingScanEnabled": true
+ }
+ }
+ ]
+ }
+ },
+ {
+ "name": "MailContacts",
+ "label": "Set contact e-mails",
+ "cat": "Global Standards",
+ "tag": [],
+ "impact": "Low Impact",
+ "helpText": "Sets the organization's notification contacts: technical, security, marketing and general/privacy. Only configured contacts are enforced.",
+ "executiveText": "Keeps Microsoft's service, security and privacy notifications flowing to the right mailboxes instead of a former employee's.",
+ "docsDescription": "Grades only the configured contacts: marketing as contains, security+technical as a set against the technical notification list, and the general contact against the privacy profile. Remediation writes only the configured members.",
+ "impactColour": "info",
+ "addedDate": "2026-08-16",
+ "powershellEquivalent": "Graph: PATCH organization",
+ "recommendedBy": [],
+ "requiredCapabilities": [],
+ "disabledFeatures": {
+ "report": false,
+ "warn": false,
+ "remediate": false
+ },
+ "secureScoreImpact": 0,
+ "compare": "subset",
+ "variables": {
+ "GeneralContact": {
+ "type": "textField",
+ "label": "General/privacy contact email",
+ "omitWhenBlank": true
+ },
+ "SecurityContact": {
+ "type": "textField",
+ "label": "Security contact email",
+ "omitWhenBlank": true
+ },
+ "MarketingContact": {
+ "type": "textField",
+ "label": "Marketing contact email",
+ "omitWhenBlank": true
+ },
+ "TechContact": {
+ "type": "textField",
+ "label": "Technical contact email",
+ "omitWhenBlank": true
+ }
+ },
+ "read": {
+ "cacheType": "Organization"
+ },
+ "prepare": "Get-CIPPBaselineMailContactsState",
+ "remediate": {
+ "executor": "MailContacts",
+ "generalContact": "%GeneralContact%",
+ "securityContact": "%SecurityContact%",
+ "marketingContact": "%MarketingContact%",
+ "techContact": "%TechContact%"
+ }
+ },
+ {
+ "name": "PhishProtection",
+ "label": "Enable Phishing Protection system via branding CSS",
+ "cat": "Global Standards",
+ "tag": [],
+ "impact": "Low Impact",
+ "helpText": "Adds branding to the logon page that only appears if the url is not login.microsoftonline.com. This potentially prevents AITM attacks via EvilNginx, and automatically generates alerts if a clone of your login page is found.",
+ "executiveText": "Adds a hidden canary to the company sign-in page that exposes cloned phishing pages and alerts when one is found, protecting staff credentials from adversary-in-the-middle attacks.",
+ "docsDescription": "Grades whether the default branding localization's custom CSS contains the tenant's clone-detection canary (the clone.cipp.app background-image URL carrying this instance's CIPPURL from the Config table). The branding singleton reads live. Remediation strips a known malformed variant, creates the default localization when missing (Accept-Language 0, tolerating already-exists), and APPENDS the canary to the existing CSS - operator customizations are never overwritten. Report and warn are disabled as in the classic: this standard acts through remediation only.",
+ "impactColour": "info",
+ "addedDate": "2026-08-16",
+ "powershellEquivalent": "Portal only",
+ "recommendedBy": [],
+ "requiredCapabilities": [
+ "AAD_PREMIUM",
+ "AAD_PREMIUM_P2",
+ "OFFICE_BUSINESS"
+ ],
+ "disabledFeatures": {
+ "report": true,
+ "warn": true,
+ "remediate": false
+ },
+ "secureScoreImpact": 0,
+ "compare": "subset",
+ "variables": null,
+ "read": null,
+ "prepare": "Get-CIPPBaselinePhishProtectionState",
+ "remediate": {
+ "executor": "PhishProtection"
+ }
+ }
+ ]
+}
diff --git a/tests/pages/BaselineTemplateEditor.seeding.test.jsx b/tests/pages/BaselineTemplateEditor.seeding.test.jsx
new file mode 100644
index 000000000000..26f878079616
--- /dev/null
+++ b/tests/pages/BaselineTemplateEditor.seeding.test.jsx
@@ -0,0 +1,66 @@
+import React from 'react'
+import { describe, it, expect, vi } from 'vitest'
+import { screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { renderWithProviders } from '../test-utils'
+import router from '../mocks/next-router'
+import fixture from '../mocks/baseline-tenant-fixture.json'
+
+vi.mock('../../src/api/ApiCall', async () => (await import('../mocks/api-call')).apiCallMock())
+import { api, getResult, postResult } from '../mocks/api-call'
+
+import Page from '../../src/pages/tenant/baselines/template.jsx'
+
+// Route ApiGetCall by url with STABLE result identities (fresh literals per call loop
+// data-sync effects - see mocks/api-call.js).
+const baselinesResult = getResult({ data: [fixture.baseline] })
+const definitionsResult = getResult({ data: fixture.definitions })
+const customVariablesResult = getResult({ data: { Results: [] } })
+const emptyResult = getResult({ isSuccess: false })
+api.get = (opts) => {
+ if (opts?.url === '/api/ListBaselines') return baselinesResult
+ if (opts?.url === '/api/ListBaselineStandards') return definitionsResult
+ if (opts?.url === '/api/ListCustomVariables') return customVariablesResult
+ return emptyResult
+}
+api.post = postResult()
+
+router.query = { id: fixture.baseline.GUID }
+router.pathname = '/tenant/baselines/template'
+
+describe('Baseline template editor - migrated variable seeding', () => {
+ it('shows the saved MailContacts addresses after expanding the standard', async () => {
+ const user = userEvent.setup()
+ renderWithProviders( )
+
+ // Template loaded: its name is in the form and the standard is listed.
+ await waitFor(() => {
+ expect(screen.getByText('Set contact e-mails')).toBeInTheDocument()
+ })
+
+ // Expand the accordion the way an operator does (details mount lazily).
+ await user.click(screen.getByText('Set contact e-mails'))
+
+ const inputs = await screen.findAllByRole('textbox')
+ const byLabel = {}
+ for (const input of inputs) {
+ const label = input.closest('.MuiFormControl-root')?.querySelector('label')?.textContent
+ if (label) byLabel[label] = input.value
+ }
+ console.log('DBG editor fields:', JSON.stringify(byLabel))
+
+ await waitFor(() => {
+ const security = screen
+ .getAllByRole('textbox')
+ .find(
+ (input) =>
+ input
+ .closest('.MuiFormControl-root')
+ ?.querySelector('label')
+ ?.textContent?.includes('Security contact email')
+ )
+ expect(security).toBeTruthy()
+ expect(security.value).toBe('support@bezalu.com')
+ })
+ }, 30000)
+})
diff --git a/tests/pages/MessageEncryptionPage.test.jsx b/tests/pages/MessageEncryptionPage.test.jsx
new file mode 100644
index 000000000000..c6e2e72cbe9f
--- /dev/null
+++ b/tests/pages/MessageEncryptionPage.test.jsx
@@ -0,0 +1,162 @@
+import React from 'react'
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { renderWithProviders } from '../test-utils'
+import Page from '../../src/pages/email/tools/message-encryption/index.js'
+
+vi.mock('../../src/api/ApiCall', async () =>
+ (await import('../mocks/api-call')).apiCallMock()
+)
+import { api, getResult, paginatedResult, postResult } from '../mocks/api-call'
+
+const AZURE_RMS = 'https://5c6bb73b-1234.rms.na.aadrm.com/_wmcs/licensing'
+const AD_RMS = 'https://rms.contoso.local/_wmcs/licensing'
+
+// stable identity per the mock's own warning: a fresh literal per call spins the
+// effects that key off the data object
+const irmConfig = (overrides = {}) => ({
+ AzureRMSLicensingEnabled: true,
+ InternalLicensingEnabled: true,
+ ExternalLicensingEnabled: false,
+ SimplifiedClientAccessEnabled: false,
+ TransportDecryptionSetting: 'Optional',
+ JournalReportDecryptionEnabled: true,
+ LicensingLocation: [AZURE_RMS],
+ MessageEncryptionEnabled: true,
+ AdRmsDetected: false,
+ ...overrides,
+})
+
+describe('Message Encryption page', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ api.post = postResult()
+ // ListMailboxes returns a bare array (no Results wrapper, the page's api sets no dataKey)
+ api.paginated = paginatedResult([], {
+ data: {
+ pages: [[
+ { displayName: 'Admin', UPN: 'admin@contoso.com' },
+ { displayName: 'Helpdesk', UPN: 'helpdesk@contoso.com' },
+ ]],
+ },
+ })
+ })
+
+ it('renders the current IRM state for the tenant', async () => {
+ api.get = getResult({ data: irmConfig() })
+ renderWithProviders( )
+
+ expect(await screen.findByText('Current Configuration')).toBeInTheDocument()
+ expect(screen.getByText('Enabled')).toBeInTheDocument()
+ expect(screen.getByText(AZURE_RMS)).toBeInTheDocument()
+ })
+
+ it('hides the migration warning for a cloud-only tenant', async () => {
+ api.get = getResult({ data: irmConfig() })
+ renderWithProviders( )
+
+ await screen.findByText('Current Configuration')
+ expect(screen.queryByText(/not compatible with/i)).not.toBeInTheDocument()
+ })
+
+ it('warns that AD RMS has to be migrated before message encryption can be used', async () => {
+ api.get = getResult({
+ data: irmConfig({
+ AzureRMSLicensingEnabled: false,
+ MessageEncryptionEnabled: false,
+ LicensingLocation: [AD_RMS],
+ AdRmsDetected: true,
+ }),
+ })
+ renderWithProviders( )
+
+ expect(await screen.findByText(/not compatible with/i)).toBeInTheDocument()
+ expect(
+ screen.getByRole('link', { name: 'migrated to Azure RMS' })
+ ).toBeInTheDocument()
+ })
+
+ it('keeps Run Test disabled until both mailboxes are selected', async () => {
+ const user = userEvent.setup()
+ api.get = getResult({ data: irmConfig() })
+ renderWithProviders( )
+
+ const runTest = await screen.findByRole('button', { name: 'Run Test' })
+ expect(runTest).toBeDisabled()
+
+ await user.click(screen.getByRole('combobox', { name: 'Sender' }))
+ await user.click(
+ await screen.findByRole('option', { name: 'Admin (admin@contoso.com)' })
+ )
+ expect(runTest).toBeDisabled()
+
+ await user.click(screen.getByRole('combobox', { name: 'Recipient' }))
+ await user.click(
+ await screen.findByRole('option', {
+ name: 'Helpdesk (helpdesk@contoso.com)',
+ })
+ )
+ expect(runTest).toBeEnabled()
+ })
+
+ it('posts the Test action with the entered addresses', async () => {
+ const user = userEvent.setup()
+ api.get = getResult({ data: irmConfig() })
+ renderWithProviders( )
+
+ await user.click(await screen.findByRole('combobox', { name: 'Sender' }))
+ await user.click(
+ await screen.findByRole('option', { name: 'Admin (admin@contoso.com)' })
+ )
+ await user.click(screen.getByRole('combobox', { name: 'Recipient' }))
+ await user.click(
+ await screen.findByRole('option', {
+ name: 'Helpdesk (helpdesk@contoso.com)',
+ })
+ )
+ await user.click(screen.getByRole('button', { name: 'Run Test' }))
+
+ expect(api.post.mutate).toHaveBeenCalledWith({
+ url: '/api/ExecIRMConfiguration',
+ data: {
+ tenantFilter: 'testdomain.com',
+ Action: 'Test',
+ Sender: 'admin@contoso.com',
+ Recipient: 'helpdesk@contoso.com',
+ },
+ })
+ })
+
+ it('posts the Set action with both encryption switches', async () => {
+ const user = userEvent.setup()
+ // AzureRMS already on, Encrypt button off — the state the standard fix was about
+ api.get = getResult({ data: irmConfig() })
+ renderWithProviders( )
+
+ await screen.findByText('Current Configuration')
+ await user.click(screen.getByRole('switch', { name: /Show the Encrypt button/i }))
+ await user.click(screen.getByRole('button', { name: 'Submit' }))
+
+ expect(api.post.mutate).toHaveBeenCalledWith({
+ url: '/api/ExecIRMConfiguration',
+ data: {
+ tenantFilter: 'testdomain.com',
+ Action: 'Set',
+ AzureRMSLicensingEnabled: true,
+ SimplifiedClientAccessEnabled: true,
+ },
+ })
+ })
+
+ it('surfaces a load failure', async () => {
+ api.get = getResult({ isSuccess: false, isError: true, data: undefined })
+ renderWithProviders( )
+
+ expect(
+ await screen.findByText(/Failed to load the IRM configuration/i)
+ ).toBeInTheDocument()
+ // no card, otherwise every undefined field renders as a confident "Disabled"/"No"
+ expect(screen.queryByText('Current Configuration')).not.toBeInTheDocument()
+ })
+})
diff --git a/tests/pages/UnauthenticatedPage.test.jsx b/tests/pages/UnauthenticatedPage.test.jsx
index 49507e700c88..1a4d9e315cf6 100644
--- a/tests/pages/UnauthenticatedPage.test.jsx
+++ b/tests/pages/UnauthenticatedPage.test.jsx
@@ -18,6 +18,7 @@ vi.mock('../../src/api/ApiCall', () => ({
// /.auth/me and /version.json
return authState.swa
},
+ ApiPostCall: () => ({ mutate: vi.fn(), isPending: false }),
}))
const successResult = (data) => ({
diff --git a/tests/pages/WorkerHealthPage.test.jsx b/tests/pages/WorkerHealthPage.test.jsx
index 70233904da90..7f8bf6a8f392 100644
--- a/tests/pages/WorkerHealthPage.test.jsx
+++ b/tests/pages/WorkerHealthPage.test.jsx
@@ -1,6 +1,6 @@
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
-import { screen, waitFor } from '@testing-library/react'
+import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '../test-utils'
import Page from '../../src/pages/cipp/advanced/container-management/worker-health.js'
@@ -8,6 +8,7 @@ import Page from '../../src/pages/cipp/advanced/container-management/worker-heal
vi.mock('../../src/api/ApiCall', async () => (await import('../mocks/api-call')).apiCallMock())
import { api, getResult, paginatedResult, postResult } from '../mocks/api-call'
import { ApiGetCallWithPagination } from '../../src/api/ApiCall'
+import { resetOverlayHistory } from '../../src/utils/overlay-history'
// stable refs, see GraphExplorerPage.test.jsx (fresh literals per call loop the data-sync effects)
const jobsResult = paginatedResult([
@@ -51,6 +52,68 @@ describe('Worker Health page - job queue preset filters', () => {
})
})
+ // Craft marks stale queue entries (task gone by dispatch time) as Skipped — same
+ // server-side filter contract as every other status.
+ it('Skipped toggle requests server-side filtering via the Status param', async () => {
+ const user = userEvent.setup()
+ renderWithProviders( )
+ await screen.findByText('1-5 of 5')
+
+ await user.click(screen.getByRole('button', { name: 'Skipped' }))
+
+ await waitFor(() => {
+ const last = ApiGetCallWithPagination.mock.calls.at(-1)[0]
+ expect(last.queryKey).toBe('WorkerHealthJobs-2000-Skipped')
+ expect(last.data).toMatchObject({ Action: 'Jobs', Limit: '2000', Status: 'Skipped' })
+ })
+ })
+
+ // jsdom has no layout engine, so MRT's virtualized table renders no cells — drive the
+ // card view instead, where tapping a card opens the off-canvas (see CippDataTable.test.jsx).
+ it('opening a job card shows the off-canvas detail fields', async () => {
+ const cache = new Map()
+ window.matchMedia = (query) => {
+ if (!cache.has(query)) {
+ cache.set(query, {
+ matches: query.includes('max-width'),
+ media: query,
+ onchange: null,
+ addListener: () => {},
+ removeListener: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ dispatchEvent: () => false,
+ })
+ }
+ return cache.get(query)
+ }
+ try {
+ const user = userEvent.setup()
+ renderWithProviders( )
+
+ await waitFor(() => expect(screen.getByText('Job Five')).toBeInTheDocument())
+ await user.click(screen.getByText('Job Five'))
+
+ // Drawer title is the job name; scope assertions to the drawer since the card
+ // behind it renders some of the same text.
+ const drawer = await waitFor(() => {
+ const d = screen
+ .getAllByText('Job Five')
+ .map((el) => el.closest('.MuiDrawer-paper'))
+ .find(Boolean)
+ expect(d).toBeTruthy()
+ return d
+ })
+ // Started Utc is not a table column, and the Id value is hidden from the table.
+ // Job Five never started, so its StartedUtc renders as N/A.
+ expect(within(drawer).getByText('Started Utc')).toBeInTheDocument()
+ expect(within(drawer).getByText('j5')).toBeInTheDocument()
+ } finally {
+ resetOverlayHistory()
+ delete window.matchMedia
+ }
+ }, 30000) // card list mount + drawer transition; default 5000ms testTimeout flakes under load (see GraphExplorerPage)
+
it('All toggle drops the Status param instead of sending an empty string', async () => {
const user = userEvent.setup()
renderWithProviders( )
diff --git a/tests/theme/input-zoom.test.js b/tests/theme/input-zoom.test.js
new file mode 100644
index 000000000000..4a47e93e3e84
--- /dev/null
+++ b/tests/theme/input-zoom.test.js
@@ -0,0 +1,16 @@
+import { describe, it, expect } from "vitest";
+import { createTheme } from "../../src/theme";
+
+// iOS Safari zooms the viewport when a focused input renders text below 16px, and it does
+// not zoom back out afterwards. Every MUI input must reach 16px on coarse pointers.
+const COARSE = "@media (pointer: coarse)";
+
+describe("input font size on touch devices", () => {
+ const theme = createTheme({ colorPreset: "orange", contrast: "high", paletteMode: "light" });
+
+ it.each(["MuiInputBase", "MuiFilledInput"])("%s inputs reach 16px on coarse pointers", (key) => {
+ const input = theme.components[key].styleOverrides.input;
+ expect(input.fontSize).toBeLessThan(16); // pointer devices stay compact
+ expect(input[COARSE]?.fontSize).toBe(16);
+ });
+});
diff --git a/tests/theme/mobile-gutters.test.js b/tests/theme/mobile-gutters.test.js
new file mode 100644
index 000000000000..bf17a4fd4c6b
--- /dev/null
+++ b/tests/theme/mobile-gutters.test.js
@@ -0,0 +1,47 @@
+import { describe, it, expect } from "vitest";
+import { createTheme } from "../../src/theme";
+
+// Card gutters are set once in the theme and paid at every nesting level: a card inside an
+// accordion inside a page card spends most of a phone's width on chrome before any content
+// gets a pixel. These have to stay narrower below md — and desktop has to keep its 24px.
+const MOBILE = "@media (max-width: 899.95px)";
+
+describe("horizontal gutters on small screens", () => {
+ const theme = createTheme({ colorPreset: "orange", contrast: "high", paletteMode: "light" });
+ const root = (key) => theme.components[key].styleOverrides.root;
+
+ it.each(["MuiCardContent", "MuiCardHeader", "MuiCardActions"])(
+ "%s trims its 24px gutters on a phone",
+ (key) => {
+ expect(root(key).paddingLeft).toBe(24);
+ expect(root(key)[MOBILE]?.paddingLeft).toBe(16);
+ expect(root(key)[MOBILE]?.paddingRight).toBe(16);
+ }
+ );
+
+ it.each(["MuiAccordionSummary", "MuiAccordionDetails"])(
+ "%s halves the padding it adds inside a card",
+ (key) => {
+ expect(root(key)[MOBILE]?.paddingLeft).toBe(8);
+ expect(root(key)[MOBILE]?.paddingRight).toBe(8);
+ }
+ );
+
+ // `:first-of-type` counts per element type, so an actions row of [caption div, button,
+ // button] gave the first button no margin and the second 16px. Invisible in a row; once the
+ // row stacks on a phone the two buttons sit at different left edges and different widths.
+ it("spaces dialog actions with gap on a phone, not a margin the stack inherits", () => {
+ const actions = root("MuiDialogActions");
+ expect(actions["&>:not(:first-of-type)"].marginLeft).toBe(16);
+ expect(actions[MOBILE]?.["&>:not(:first-of-type)"]?.marginLeft).toBe(0);
+ expect(actions[MOBILE]?.gap).toBe(8);
+ expect(actions[MOBILE]?.paddingLeft).toBe(16);
+ });
+
+ it("leaves vertical rhythm alone — width is what runs out, not height", () => {
+ const content = root("MuiCardContent");
+ expect(content.paddingTop).toBe(20);
+ expect(content[MOBILE]?.paddingTop).toBeUndefined();
+ expect(content[MOBILE]?.paddingBottom).toBeUndefined();
+ });
+});
diff --git a/tests/theme/tooltip-touch.test.jsx b/tests/theme/tooltip-touch.test.jsx
new file mode 100644
index 000000000000..d7b306af5668
--- /dev/null
+++ b/tests/theme/tooltip-touch.test.jsx
@@ -0,0 +1,56 @@
+import React from "react";
+import { describe, it, expect } from "vitest";
+import { screen, fireEvent, waitFor } from "@testing-library/react";
+import { Tooltip, Button } from "@mui/material";
+import { createTheme } from "../../src/theme";
+import { renderWithTheme } from "../test-utils";
+
+// MUI's Tooltip attaches no touchmove and no scroll listener: handleTouchStart arms a 700ms
+// timer that opens the tooltip, and only handleTouchEnd schedules the close. A press held
+// through a scroll therefore opens one and nothing closes it while the finger is down.
+describe("tooltips on touch", () => {
+ it("is disabled by default across the app", () => {
+ const theme = createTheme({ colorPreset: "orange", contrast: "high", paletteMode: "light" });
+ expect(theme.components.MuiTooltip.defaultProps.disableTouchListener).toBe(true);
+ });
+
+ // Real timers: MUI arms enterDelay inside the enterTouchDelay callback, and the nested
+ // pair does not advance reliably under fake ones — a faked version of this test passed
+ // with the fix removed, which is worse than no test.
+ it("does not open from a long press", async () => {
+ renderWithTheme(
+
+ Users
+
+ );
+
+ fireEvent.touchStart(screen.getByRole("button"));
+ await new Promise((resolve) => setTimeout(resolve, 400));
+
+ expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
+ });
+
+ it("still opens on hover, where a tooltip belongs", async () => {
+ renderWithTheme(
+
+ Users
+
+ );
+
+ fireEvent.mouseOver(screen.getByRole("button"));
+
+ expect(await screen.findByRole("tooltip")).toHaveTextContent("Users in this tenant");
+ });
+
+ it("lets a site opt back in", async () => {
+ renderWithTheme(
+
+ Field
+
+ );
+
+ fireEvent.touchStart(screen.getByRole("button"));
+
+ await waitFor(() => expect(screen.getByRole("tooltip")).toBeInTheDocument());
+ });
+});
diff --git a/tests/utils/csv-field-values.test.js b/tests/utils/csv-field-values.test.js
new file mode 100644
index 000000000000..027262e2be95
--- /dev/null
+++ b/tests/utils/csv-field-values.test.js
@@ -0,0 +1,68 @@
+import {
+ extractCsvColumnValues,
+ mergeCsvFormFields,
+ normalizeAutoCompleteValues,
+} from '../../src/utils/csv-field-values'
+
+describe('csv-field-values', () => {
+ describe('extractCsvColumnValues', () => {
+ it('extracts values for a matching column (case-insensitive, trimmed header)', () => {
+ const rows = [
+ { userPrincipalName: 'a@contoso.com' },
+ { ' UserPrincipalName ': 'b@contoso.com' },
+ { other: 'skip' },
+ ]
+ expect(extractCsvColumnValues(rows, 'userPrincipalName')).toEqual([
+ 'a@contoso.com',
+ 'b@contoso.com',
+ ])
+ })
+
+ it('returns empty when the column header is missing', () => {
+ const rows = [{ 'User Principal Name': 'a@contoso.com' }]
+ expect(extractCsvColumnValues(rows, 'userPrincipalName')).toEqual([])
+ })
+ })
+
+ describe('normalizeAutoCompleteValues', () => {
+ it('flattens {label,value} objects to string values', () => {
+ expect(
+ normalizeAutoCompleteValues([
+ { label: 'Alice', value: 'id-1' },
+ { label: 'Bob', value: 'id-2' },
+ ])
+ ).toEqual(['id-1', 'id-2'])
+ })
+ })
+
+ describe('mergeCsvFormFields', () => {
+ const fields = [
+ { type: 'autoComplete', name: 'users', csvColumn: 'userPrincipalName' },
+ ]
+
+ it('merges autocomplete and CSV values and drops the companion field', () => {
+ const merged = mergeCsvFormFields(
+ {
+ users: [{ label: 'Alice', value: 'id-1' }],
+ users__csv: [{ userPrincipalName: 'csv@contoso.com' }],
+ },
+ fields
+ )
+ expect(merged).toEqual({
+ users: ['id-1', 'csv@contoso.com'],
+ })
+ })
+
+ it('yields an empty users array when CSV rows lack the configured column', () => {
+ const merged = mergeCsvFormFields(
+ {
+ users: [],
+ users__csv: [{ 'User Principal Name': 'a@contoso.com' }],
+ },
+ fields
+ )
+ expect(merged.users).toEqual([])
+ expect(merged.users__csv).toBeUndefined()
+ })
+ })
+})
diff --git a/tests/utils/get-cipp-formatting.test.jsx b/tests/utils/get-cipp-formatting.test.jsx
index 77e3cfe21b39..ac82c4d0cafb 100644
--- a/tests/utils/get-cipp-formatting.test.jsx
+++ b/tests/utils/get-cipp-formatting.test.jsx
@@ -124,3 +124,56 @@ describe('getCippFormatting (component mode)', () => {
expect(getCippFormatting(null, 'Severity', 'text')).toBe('No data')
})
})
+
+// Role members exported as raw JSON instead of a name list.
+// Shape mirrors Invoke-ListRoles: { displayName, userPrincipalName, id, directoryScopeId }.
+describe('getCippFormatting Members (roles export)', () => {
+ const members = [
+ {
+ displayName: 'Alice Adams',
+ userPrincipalName: 'alice@contoso.com',
+ id: '11111111-1111-1111-1111-111111111111',
+ directoryScopeId: '/',
+ },
+ {
+ displayName: 'Bob Brown',
+ userPrincipalName: 'bob@contoso.com',
+ id: '22222222-2222-2222-2222-222222222222',
+ directoryScopeId: '/',
+ },
+ ]
+
+ it('joins member display names in text mode', () => {
+ expect(getCippFormatting(members, 'Members', 'text')).toBe('Alice Adams, Bob Brown')
+ })
+
+ it('never emits JSON or [object Object] on the CSV export path', () => {
+ // csvExportButton calls this with flatten=false first, and only falls back to
+ // per-member JSON.stringify when the result still contains [object Object].
+ const exported = getCippFormatting(members, 'Members', 'text', false, false)
+ expect(exported).toBe('Alice Adams, Bob Brown')
+ expect(exported).not.toContain('[object Object]')
+ expect(exported).not.toContain('displayName')
+ expect(exported).not.toContain('{')
+ })
+
+ it('falls back to UPN then id when a display name is missing', () => {
+ expect(
+ getCippFormatting(
+ [{ userPrincipalName: 'svc@contoso.com' }, { id: 'abc-123' }],
+ 'Members',
+ 'text'
+ )
+ ).toBe('svc@contoso.com, abc-123')
+ })
+
+ it('renders an empty member list as an empty string', () => {
+ expect(getCippFormatting([], 'Members', 'text')).toBe('')
+ })
+
+ it('still renders the items button in component mode', () => {
+ const cell = getCippFormatting(members, 'Members')
+ expect(typeof cell).toBe('object')
+ expect(cell?.props?.tableTitle).toBe('Members')
+ })
+})
diff --git a/tests/utils/get-filtered-portals.test.js b/tests/utils/get-filtered-portals.test.js
new file mode 100644
index 000000000000..bbd1fbc0ad17
--- /dev/null
+++ b/tests/utils/get-filtered-portals.test.js
@@ -0,0 +1,49 @@
+import { describe, it, expect } from "vitest";
+import { getFilteredPortals } from "../../src/utils/get-filtered-portals";
+import Portals from "../../src/data/portals";
+
+const names = (portals) => portals.map((p) => p.name);
+
+// Pre-existing mismatch, documented rather than fixed here: portals.json splits Power
+// Platform into _Admin/_Maker entries, while the defaults map (and the preferences toggle,
+// and dashboardv1) still key on the un-suffixed Power_Platform_Portal — so those two are
+// filtered out for everyone and their preference toggle controls nothing.
+const UNREACHABLE_BY_DEFAULT = ["Power_Platform_Portal_Admin", "Power_Platform_Portal_Maker"];
+const defaultVisible = names(Portals).filter((n) => !UNREACHABLE_BY_DEFAULT.includes(n));
+
+describe("getFilteredPortals", () => {
+ it("returns every default-on portal when settings carry no preferences", () => {
+ expect(names(getFilteredPortals({}))).toEqual(defaultVisible);
+ });
+
+ it("tolerates undefined settings", () => {
+ expect(names(getFilteredPortals(undefined))).toEqual(defaultVisible);
+ });
+
+ it("hides a portal turned off in UserSpecificSettings", () => {
+ const result = getFilteredPortals({
+ UserSpecificSettings: { portalLinks: { Exchange_Portal: false } },
+ });
+
+ expect(names(result)).not.toContain("Exchange_Portal");
+ expect(names(result)).toContain("M365_Portal");
+ });
+
+ it("falls back to tenant-level portalLinks when no user-specific ones exist", () => {
+ const result = getFilteredPortals({ portalLinks: { Azure_Portal: false } });
+
+ expect(names(result)).not.toContain("Azure_Portal");
+ expect(names(result)).toContain("M365_Portal");
+ });
+
+ it("prefers UserSpecificSettings over tenant-level portalLinks", () => {
+ const result = getFilteredPortals({
+ portalLinks: { Teams_Portal: false },
+ UserSpecificSettings: { portalLinks: { Entra_Portal: false } },
+ });
+
+ // The user-specific object wins outright — the tenant-level opt-out is not merged in.
+ expect(names(result)).toContain("Teams_Portal");
+ expect(names(result)).not.toContain("Entra_Portal");
+ });
+});
diff --git a/tests/utils/impersonation.test.js b/tests/utils/impersonation.test.js
new file mode 100644
index 000000000000..2c6dda9c046e
--- /dev/null
+++ b/tests/utils/impersonation.test.js
@@ -0,0 +1,119 @@
+import {
+ getImpersonatedRole,
+ subscribeImpersonation,
+ enterImpersonation,
+ exitImpersonation,
+ impersonationCacheParams,
+} from '../../src/utils/impersonation'
+
+const KEY = 'cipp_impersonate_role'
+
+describe('impersonation store', () => {
+ let reloadSpy
+
+ beforeEach(() => {
+ window.localStorage.clear()
+ // jsdom's location.reload is not configurable via vi.spyOn directly
+ reloadSpy = vi.fn()
+ Object.defineProperty(window, 'location', {
+ value: { ...window.location, reload: reloadSpy },
+ writable: true,
+ })
+ })
+
+ it('is null by default and reflects the stored role', () => {
+ expect(getImpersonatedRole()).toBeNull()
+ window.localStorage.setItem(KEY, 'helpdesk')
+ expect(getImpersonatedRole()).toBe('helpdesk')
+ })
+
+ it('enterImpersonation lowercases, stores, clears caches and reloads', () => {
+ window.localStorage.setItem('REACT_QUERY_OFFLINE_CACHE', 'x')
+ window.localStorage.setItem('REACT_QUERY_OFFLINE_CACHE_extra', 'y')
+ window.localStorage.setItem('app.settings', 'keep-me')
+ const queryClient = { clear: vi.fn() }
+
+ enterImpersonation('HelpDesk', queryClient)
+
+ expect(window.localStorage.getItem(KEY)).toBe('helpdesk')
+ expect(queryClient.clear).toHaveBeenCalledTimes(1)
+ expect(window.localStorage.getItem('REACT_QUERY_OFFLINE_CACHE')).toBeNull()
+ expect(window.localStorage.getItem('REACT_QUERY_OFFLINE_CACHE_extra')).toBeNull()
+ expect(window.localStorage.getItem('app.settings')).toBe('keep-me')
+ expect(reloadSpy).toHaveBeenCalledTimes(1)
+ })
+
+ it('exitImpersonation removes the key, clears caches and reloads', () => {
+ window.localStorage.setItem(KEY, 'helpdesk')
+ window.localStorage.setItem('REACT_QUERY_OFFLINE_CACHE', 'x')
+ const queryClient = { clear: vi.fn() }
+
+ exitImpersonation(queryClient)
+
+ expect(window.localStorage.getItem(KEY)).toBeNull()
+ expect(window.localStorage.getItem('REACT_QUERY_OFFLINE_CACHE')).toBeNull()
+ expect(reloadSpy).toHaveBeenCalledTimes(1)
+ })
+
+ it('notifies subscribers on enter and exit, and unsubscribe works', () => {
+ const listener = vi.fn()
+ const unsubscribe = subscribeImpersonation(listener)
+
+ enterImpersonation('readonly', { clear: vi.fn() })
+ expect(listener).toHaveBeenCalledTimes(1)
+
+ exitImpersonation({ clear: vi.fn() })
+ expect(listener).toHaveBeenCalledTimes(2)
+
+ unsubscribe()
+ enterImpersonation('editor', { clear: vi.fn() })
+ expect(listener).toHaveBeenCalledTimes(2)
+ })
+
+ it('impersonationCacheParams segregates the Craft cache key only while impersonating', () => {
+ expect(impersonationCacheParams()).toEqual({})
+ window.localStorage.setItem(KEY, 'helpdesk')
+ expect(impersonationCacheParams()).toEqual({ _imp: 'helpdesk' })
+ })
+
+ it('survives a throwing localStorage without crashing', () => {
+ const original = window.localStorage
+ Object.defineProperty(window, 'localStorage', {
+ value: {
+ getItem: () => {
+ throw new Error('denied')
+ },
+ setItem: () => {
+ throw new Error('denied')
+ },
+ removeItem: () => {
+ throw new Error('denied')
+ },
+ },
+ configurable: true,
+ })
+
+ expect(getImpersonatedRole()).toBeNull()
+ expect(() => exitImpersonation({ clear: vi.fn() })).not.toThrow()
+
+ Object.defineProperty(window, 'localStorage', { value: original, configurable: true })
+ })
+})
+
+describe('buildVersionedHeaders impersonation header', () => {
+ beforeEach(() => {
+ window.localStorage.clear()
+ global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ version: '1.0' }) })
+ })
+
+ it('adds x-cipp-impersonate-role only while impersonating', async () => {
+ const { buildVersionedHeaders } = await import('../../src/utils/cippVersion')
+
+ const plain = await buildVersionedHeaders()
+ expect(plain['x-cipp-impersonate-role']).toBeUndefined()
+
+ window.localStorage.setItem(KEY, 'helpdesk')
+ const impersonated = await buildVersionedHeaders()
+ expect(impersonated['x-cipp-impersonate-role']).toBe('helpdesk')
+ })
+})
diff --git a/tests/utils/overlay-history.test.js b/tests/utils/overlay-history.test.js
new file mode 100644
index 000000000000..a62820d5510a
--- /dev/null
+++ b/tests/utils/overlay-history.test.js
@@ -0,0 +1,170 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ installOverlayHistory,
+ pushOverlayEntry,
+ releaseOverlayEntry,
+ resetOverlayHistory,
+} from "../../src/utils/overlay-history";
+
+// The shape Next's pages router keeps in history.state for the current route.
+const routeState = (as) => ({ __N: true, url: as, as, key: `key-${as}`, options: {} });
+
+// jsdom traverses asynchronously, same as a browser: back() queues the task and popstate
+// lands later. Every assertion about a back press has to wait for it.
+const nextPop = () =>
+ new Promise((resolve) => window.addEventListener("popstate", resolve, { once: true }));
+
+const goBack = async () => {
+ const settled = nextPop();
+ window.history.back();
+ await settled;
+};
+
+// A browser fires a single popstate for a multi-entry jump, e.g. the long-press back menu.
+const goTo = async (delta) => {
+ const settled = nextPop();
+ window.history.go(delta);
+ await settled;
+};
+
+beforeEach(() => {
+ window.history.replaceState(routeState("/identity/users"), "");
+});
+
+afterEach(() => {
+ resetOverlayHistory();
+});
+
+describe("overlay history", () => {
+ it("closes the overlay on a back press instead of letting the page navigate", async () => {
+ const close = vi.fn();
+ const url = window.location.href;
+ installOverlayHistory();
+ pushOverlayEntry(close);
+
+ // The entry sits at the same url — nothing about the page changed.
+ expect(window.location.href).toBe(url);
+ await goBack();
+
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+
+ it("keeps the pushed entry recognisable to Next's router", () => {
+ installOverlayHistory();
+ pushOverlayEntry(vi.fn());
+
+ // Cloning the router's own state is what makes this entry survive a navigation away and
+ // back: Next ignores any history entry without __N, and would leave the app on a blank
+ // route if it landed on one.
+ expect(window.history.state.__N).toBe(true);
+ expect(window.history.state.as).toBe("/identity/users");
+ });
+
+ it("dismisses one overlay per back press, deepest first", async () => {
+ const closeOuter = vi.fn();
+ const closeInner = vi.fn();
+ installOverlayHistory();
+ pushOverlayEntry(closeOuter);
+ pushOverlayEntry(closeInner);
+
+ await goBack();
+ expect(closeInner).toHaveBeenCalledTimes(1);
+ expect(closeOuter).not.toHaveBeenCalled();
+
+ await goBack();
+ expect(closeOuter).toHaveBeenCalledTimes(1);
+ });
+
+ it("takes its history entry back when the overlay is closed by hand", async () => {
+ const close = vi.fn();
+ installOverlayHistory();
+ const entry = pushOverlayEntry(close);
+
+ const settled = nextPop();
+ releaseOverlayEntry(entry);
+ await settled;
+
+ // The component closed itself, so the callback must not fire again — and the entry is
+ // gone, so the user's next back press belongs to the page.
+ expect(close).not.toHaveBeenCalled();
+ expect(window.history.state.__cippOverlay).toBeUndefined();
+ });
+
+ it("leaves history alone when its entry has been buried by a navigation", () => {
+ const back = vi.spyOn(window.history, "back");
+ const close = vi.fn();
+ installOverlayHistory();
+ const entry = pushOverlayEntry(close);
+
+ // A link inside the overlay navigated: Next pushed a route entry over ours.
+ window.history.pushState(routeState("/identity/users/user"), "");
+ releaseOverlayEntry(entry);
+
+ // Popping here would drag the user back off the page they just opened.
+ expect(back).not.toHaveBeenCalled();
+ back.mockRestore();
+ });
+});
+
+describe("overlay history / Next router handoff", () => {
+ // Next's own popstate listener is registered at app boot, before ours, and calls
+ // beforePopState from inside it. Registering this listener before installOverlayHistory
+ // reproduces that ordering — which matters, because the answer depends on state our
+ // listener is about to overwrite.
+ const withRouter = () => {
+ const answers = [];
+ let handler = null;
+ const listener = (event) => {
+ if (handler) answers.push(handler(event.state));
+ };
+ window.addEventListener("popstate", listener);
+ installOverlayHistory({
+ beforePopState: (cb) => {
+ handler = cb;
+ },
+ });
+ return {
+ answers,
+ teardown: () => window.removeEventListener("popstate", listener),
+ };
+ };
+
+ it("stops Next from re-rendering the route when the pop was ours", async () => {
+ const router = withRouter();
+ pushOverlayEntry(vi.fn());
+
+ await goBack();
+
+ // false means "handled downstream". Letting Next through would emit route events and
+ // reset scroll — a long list would jump to the top every time a row was dismissed.
+ expect(router.answers).toEqual([false]);
+ router.teardown();
+ });
+
+ it("leaves ordinary back presses to Next", async () => {
+ const router = withRouter();
+ window.history.pushState(routeState("/identity/users"), "");
+
+ await goBack();
+
+ expect(router.answers).toEqual([true]);
+ router.teardown();
+ });
+
+ it("leaves a real navigation to Next even with an overlay open", async () => {
+ window.history.replaceState(routeState("/identity/devices"), "");
+ window.history.pushState(routeState("/identity/users"), "");
+ const router = withRouter();
+ const close = vi.fn();
+ pushOverlayEntry(close);
+
+ // The long-press back menu jumps straight past our entry to another route. That pop
+ // lands on a different page, so Next has to run — and the overlay closes with the page
+ // it belonged to.
+ await goTo(-2);
+
+ expect(router.answers).toEqual([true]);
+ expect(close).toHaveBeenCalledTimes(1);
+ router.teardown();
+ });
+});
diff --git a/tests/utils/permission-rules.test.js b/tests/utils/permission-rules.test.js
new file mode 100644
index 000000000000..a78e863be3c8
--- /dev/null
+++ b/tests/utils/permission-rules.test.js
@@ -0,0 +1,151 @@
+import {
+ matchPattern,
+ flattenPermissionTree,
+ expandRules,
+ rulesToFlatMap,
+ flatMapToRules,
+ validateRulePattern,
+ buildRuleSuggestions,
+} from '../../src/utils/permission-rules'
+
+// Shape returned by /api/ExecAPIPermissionList: Cat -> Obj -> Read|ReadWrite -> functions
+const apiPermissions = {
+ CIPP: {
+ Core: { Read: {}, ReadWrite: {} },
+ },
+ Identity: {
+ User: { Read: {}, ReadWrite: {} },
+ Device: { Read: {}, ReadWrite: {} },
+ },
+ Exchange: {
+ Mailbox: { Read: {}, ReadWrite: {} },
+ },
+}
+
+const universe = flattenPermissionTree(apiPermissions)
+
+describe('matchPattern', () => {
+ it('mirrors PowerShell -like: multiple wildcards all expand', () => {
+ // The old implementation only replaced the first *; this pattern needs both.
+ expect(matchPattern('CIPP.*.Read*', 'CIPP.Core.ReadWrite')).toBe(true)
+ expect(matchPattern('*.Mailbox.*', 'Exchange.Mailbox.Read')).toBe(true)
+ })
+
+ it('treats dots as literal separators, not regex wildcards', () => {
+ expect(matchPattern('Identity.User.Read', 'IdentityXUserXRead')).toBe(false)
+ expect(matchPattern('Identity.User.Read', 'Identity.User.Read')).toBe(true)
+ })
+
+ it('is case-insensitive like -like', () => {
+ expect(matchPattern('identity.user.*', 'Identity.User.ReadWrite')).toBe(true)
+ })
+
+ it('anchors the pattern to the whole string', () => {
+ expect(matchPattern('Identity.User', 'Identity.User.Read')).toBe(false)
+ expect(matchPattern('*.Read', 'Identity.User.ReadWrite')).toBe(false)
+ })
+})
+
+describe('flattenPermissionTree', () => {
+ it('lists every Cat.Obj.Level string, sorted', () => {
+ expect(universe).toEqual([
+ 'CIPP.Core.Read',
+ 'CIPP.Core.ReadWrite',
+ 'Exchange.Mailbox.Read',
+ 'Exchange.Mailbox.ReadWrite',
+ 'Identity.Device.Read',
+ 'Identity.Device.ReadWrite',
+ 'Identity.User.Read',
+ 'Identity.User.ReadWrite',
+ ])
+ })
+
+ it('handles a missing tree', () => {
+ expect(flattenPermissionTree(undefined)).toEqual([])
+ })
+})
+
+describe('expandRules', () => {
+ it('grants includes minus excludes, exclude wins', () => {
+ const { matched, excludedBy } = expandRules(
+ { Include: ['Identity.*'], Exclude: ['Identity.Device.*'] },
+ universe,
+ )
+ expect(matched).toEqual(['Identity.User.Read', 'Identity.User.ReadWrite'])
+ expect(excludedBy['Identity.Device.Read']).toBe('Identity.Device.*')
+ })
+
+ it('reports per-pattern match counts for the live preview', () => {
+ const { includeCounts, excludeCounts } = expandRules(
+ { Include: ['*.Read', 'Identity.Uesr.*'], Exclude: ['CIPP.*'] },
+ universe,
+ )
+ expect(includeCounts['*.Read']).toBe(4)
+ // Typo'd pattern matches nothing — this is what powers the zero-match warning.
+ expect(includeCounts['Identity.Uesr.*']).toBe(0)
+ expect(excludeCounts['CIPP.*']).toBe(1)
+ })
+
+ it('accepts autocomplete option objects as rule entries', () => {
+ const { matched } = expandRules(
+ { Include: [{ label: 'Identity.User.Read', value: 'Identity.User.Read' }], Exclude: [] },
+ universe,
+ )
+ expect(matched).toEqual(['Identity.User.Read'])
+ })
+})
+
+describe('rulesToFlatMap', () => {
+ it('produces the editor grid map with ReadWrite beating Read', () => {
+ const flat = rulesToFlatMap({ Include: ['Identity.User.*'], Exclude: [] }, apiPermissions)
+ expect(flat['IdentityUser']).toBe('Identity.User.ReadWrite')
+ expect(flat['IdentityDevice']).toBe('Identity.Device.None')
+ })
+
+ it('floors CIPP.Core at Read so a saved snapshot never locks out sign-in', () => {
+ const flat = rulesToFlatMap({ Include: ['Exchange.*'], Exclude: [] }, apiPermissions)
+ expect(flat['CIPPCore']).toBe('CIPP.Core.Read')
+ })
+
+ it('honours excludes', () => {
+ const flat = rulesToFlatMap(
+ { Include: ['Identity.*'], Exclude: ['Identity.User.ReadWrite'] },
+ apiPermissions,
+ )
+ expect(flat['IdentityUser']).toBe('Identity.User.Read')
+ })
+})
+
+describe('flatMapToRules', () => {
+ it('converts a grid map to concrete-string rules, dropping None', () => {
+ expect(
+ flatMapToRules({
+ IdentityUser: 'Identity.User.ReadWrite',
+ IdentityDevice: 'Identity.Device.None',
+ CIPPCore: 'CIPP.Core.Read',
+ }),
+ ).toEqual({ Include: ['CIPP.Core.Read', 'Identity.User.ReadWrite'], Exclude: [] })
+ })
+})
+
+describe('validateRulePattern', () => {
+ it.each(['*', '*.Read', 'Identity.*', 'Identity.User.*', 'Identity.User.ReadWrite'])(
+ 'accepts %s',
+ (pattern) => expect(validateRulePattern(pattern)).toBe(true),
+ )
+
+ it.each(['', 'Identity.User.Read.Extra', 'Identity User', 'Identity..Read', 'a.b.c;drop'])(
+ 'rejects %s',
+ (pattern) => expect(validateRulePattern(pattern)).toBe(false),
+ )
+})
+
+describe('buildRuleSuggestions', () => {
+ it('offers global, category and concrete patterns', () => {
+ const values = buildRuleSuggestions(apiPermissions).map((o) => o.value)
+ expect(values).toContain('*')
+ expect(values).toContain('Identity.*')
+ expect(values).toContain('Identity.User.*')
+ expect(values).toContain('Identity.User.ReadWrite')
+ })
+})
diff --git a/tests/utils/resolve-row-templates.test.js b/tests/utils/resolve-row-templates.test.js
new file mode 100644
index 000000000000..e89ef97dace5
--- /dev/null
+++ b/tests/utils/resolve-row-templates.test.js
@@ -0,0 +1,121 @@
+import {
+ getNestedValue,
+ resolveRowTemplates,
+ attachParentRow,
+ getRowTenant,
+} from '../../src/utils/resolve-row-templates'
+
+const row = {
+ id: 'abc-123',
+ displayName: 'Finance',
+ siteId: 'site-1',
+ nested: { mail: 'finance@contoso.com' },
+}
+
+describe('getNestedValue', () => {
+ it('reads a top-level field', () => {
+ expect(getNestedValue(row, 'id')).toBe('abc-123')
+ })
+
+ it('reads a dotted path', () => {
+ expect(getNestedValue(row, 'nested.mail')).toBe('finance@contoso.com')
+ })
+
+ it('returns undefined for a missing path', () => {
+ expect(getNestedValue(row, 'missing.path')).toBeUndefined()
+ })
+})
+
+describe('resolveRowTemplates', () => {
+ it('replaces [id] in a string', () => {
+ expect(resolveRowTemplates('group-members-[id]', row)).toBe(
+ 'group-members-abc-123'
+ )
+ })
+
+ it('replaces a nested path', () => {
+ expect(resolveRowTemplates('mail=[nested.mail]', row)).toBe(
+ 'mail=finance@contoso.com'
+ )
+ })
+
+ it('leaves an unmatched token in place', () => {
+ expect(resolveRowTemplates('x-[unknown]', row)).toBe('x-[unknown]')
+ })
+
+ it('walks objects used as api.data', () => {
+ expect(
+ resolveRowTemplates(
+ { someId: '[id]', extra: true, siteId: '[siteId]' },
+ row
+ )
+ ).toEqual({ someId: 'abc-123', extra: true, siteId: 'site-1' })
+ })
+
+ it('leaves booleans and numbers alone', () => {
+ expect(resolveRowTemplates(true, row)).toBe(true)
+ expect(resolveRowTemplates(999, row)).toBe(999)
+ })
+
+ it('walks arrays', () => {
+ expect(resolveRowTemplates(['[id]', 1], row)).toEqual(['abc-123', 1])
+ })
+})
+
+describe('attachParentRow', () => {
+ const parentRow = { id: 'group-1', displayName: 'Finance' }
+
+ it('attaches the opening row as parent', () => {
+ expect(attachParentRow({ id: 'member-1' }, parentRow)).toEqual({
+ id: 'member-1',
+ parent: parentRow,
+ })
+ })
+
+ it('leaves a row unchanged when there is no parent', () => {
+ const child = { id: 'member-1' }
+ expect(attachParentRow(child, undefined)).toBe(child)
+ })
+
+ it('maps arrays', () => {
+ expect(attachParentRow([{ id: 'a' }, { id: 'b' }], parentRow)).toEqual([
+ { id: 'a', parent: parentRow },
+ { id: 'b', parent: parentRow },
+ ])
+ })
+
+ it('chains an existing parent when the opening row is not nested', () => {
+ const child = { id: 'member-1', parent: { id: 'api-parent' } }
+ expect(attachParentRow(child, parentRow).parent).toEqual({
+ id: 'group-1',
+ displayName: 'Finance',
+ parent: { id: 'api-parent' },
+ })
+ })
+
+ it('keeps a nested table chain instead of overwriting it', () => {
+ const nestedParent = { id: 'member-1', parent: parentRow }
+ const grandchild = { id: 'license-1' }
+ expect(attachParentRow(grandchild, nestedParent).parent).toBe(nestedParent)
+ })
+})
+
+describe('getRowTenant', () => {
+ it('returns the current tenant outside AllTenants', () => {
+ expect(
+ getRowTenant({ Tenant: 'other.com' }, 'contoso.com')
+ ).toBe('contoso.com')
+ })
+
+ it('prefers the row tenant in AllTenants', () => {
+ expect(getRowTenant({ Tenant: 'child.com' }, 'AllTenants')).toBe(
+ 'child.com'
+ )
+ })
+
+ it('falls back to the nested parent tenant', () => {
+ expect(
+ getRowTenant({ parent: { Tenant: 'parent.com' } }, 'AllTenants')
+ ).toBe('parent.com')
+ })
+})
diff --git a/tests/viewport.js b/tests/viewport.js
new file mode 100644
index 000000000000..4a6dff2ce578
--- /dev/null
+++ b/tests/viewport.js
@@ -0,0 +1,41 @@
+/**
+ * Resizes the story iframe, for stories that measure layout or drive a breakpoint.
+ *
+ * Three things this exists to get right:
+ * - The VIEWPORT has to shrink, not a wrapper element. MUI breakpoints are media queries,
+ * so a 390px-wide Box inside a desktop-width iframe still renders every `md` branch.
+ * - The import has to be lazy. At module scope `@vitest/browser/context` throws
+ * "can be imported only inside the Browser Mode", which breaks the story for anyone who
+ * opens it in the Storybook app rather than the test runner.
+ * - Every story shares one page. A story that shrinks the viewport and never restores it
+ * leaves the next story running at phone width — which is an ordering-dependent failure,
+ * so a desktop story must claim its width rather than assume it.
+ *
+ * Returns false when there is no runner driving the iframe, so a play function can skip
+ * measurements that would otherwise assert against whatever width Storybook happens to use.
+ *
+ * NOTE: resizing does not synchronously re-render. `useMediaQuery` updates from a matchMedia
+ * change listener, i.e. a tick later — so the first assertion that depends on the new
+ * breakpoint must be a `findBy*` or wrapped in `waitFor`, never a bare `getBy*`. Verified by
+ * probe: right after this resolves, the mobile branch is not in the DOM yet. A preceding
+ * `await` on something present in BOTH branches does not settle it — it only makes the race
+ * usually go your way, which is how CippWizardPage passed locally and failed in CI.
+ */
+const resize = async (width, height) => {
+ try {
+ const { page } = await import("@vitest/browser/context");
+ await page.viewport(width, height);
+ // Measured: window.innerWidth is ALREADY the new value when this resolves — the width is
+ // not what lags. What lags is React: matchMedia listeners fire, useMediaQuery setStates,
+ // and the breakpoint branch renders a tick later. A frame here covers the common case; it
+ // is not a guarantee, which is why callers must still findBy/waitFor (see below).
+ await new Promise((resolve) => requestAnimationFrame(resolve));
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+export const shrinkToPhoneViewport = async (width = 390, height = 844) => resize(width, height);
+
+export const growToDesktopViewport = async (width = 1280, height = 900) => resize(width, height);
diff --git a/vitest.config.mjs b/vitest.config.mjs
index 8f2672cbc123..a48c9816f9f4 100644
--- a/vitest.config.mjs
+++ b/vitest.config.mjs
@@ -17,6 +17,23 @@ const nextAliases = {
'next/link': path.resolve(dirname, 'tests/mocks/next-link.js'),
}
+// vitest gives every module its own cjs `require`, which shadows the globalThis polyfill in
+// tests/mocks/require-context.js. jsdom only - the browser project has no local require
+const requireContextPlugin = {
+ name: 'cipp-require-context',
+ enforce: 'pre',
+ transform(code, id) {
+ if (id.includes('/node_modules/') || !code.includes('require.context(')) {
+ return null
+ }
+ // lookbehind so an already-prefixed call isn't rewritten to globalThis.globalThis.require
+ return {
+ code: code.replace(/(? {
diff --git a/yarn.lock b/yarn.lock
index cdccb617fe34..db0f5d11f0dd 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1102,6 +1102,22 @@
resolved "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz#798a33950d11226a0ebb6acafa60f5594424967f"
integrity sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==
+"@emnapi/core@1.11.2":
+ version "1.11.2"
+ resolved "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz#fab0a0f3c492d11f5a9ac9065d0d73955ee1c1c9"
+ integrity sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==
+ dependencies:
+ "@emnapi/wasi-threads" "1.2.2"
+ tslib "^2.4.0"
+
+"@emnapi/core@1.9.2":
+ version "1.9.2"
+ resolved "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz#3870265ecffc7352d01ead62d8d83d8358a2d034"
+ integrity sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==
+ dependencies:
+ "@emnapi/wasi-threads" "1.2.1"
+ tslib "^2.4.0"
+
"@emnapi/core@^1.4.3":
version "1.9.1"
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.9.1.tgz#2143069c744ca2442074f8078462e51edd63c7bd"
@@ -1110,6 +1126,20 @@
"@emnapi/wasi-threads" "1.2.0"
tslib "^2.4.0"
+"@emnapi/runtime@1.11.2":
+ version "1.11.2"
+ resolved "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz#eb22f04d76febfdf4f87fdaff54c8a53f6bf0dbd"
+ integrity sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==
+ dependencies:
+ tslib "^2.4.0"
+
+"@emnapi/runtime@1.9.2":
+ version "1.9.2"
+ resolved "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz#8b469a3db160817cadb1de9050211a9d1ea84fa2"
+ integrity sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==
+ dependencies:
+ tslib "^2.4.0"
+
"@emnapi/runtime@^1.4.3", "@emnapi/runtime@^1.7.0":
version "1.9.1"
resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.9.1.tgz#115ff2a0d589865be6bd8e9d701e499c473f2a8d"
@@ -1124,6 +1154,20 @@
dependencies:
tslib "^2.4.0"
+"@emnapi/wasi-threads@1.2.1":
+ version "1.2.1"
+ resolved "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz#28fed21a1ba1ce797c44a070abc94d42f3ae8548"
+ integrity sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==
+ dependencies:
+ tslib "^2.4.0"
+
+"@emnapi/wasi-threads@1.2.2":
+ version "1.2.2"
+ resolved "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz#4c93becf5bfa3b13d1bbdcc06aee38321ad8139a"
+ integrity sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==
+ dependencies:
+ tslib "^2.4.0"
+
"@emotion/babel-plugin@^11.13.5":
version "11.13.5"
resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz#eab8d65dbded74e0ecfd28dc218e75607c4e7bc0"
@@ -1241,265 +1285,135 @@
resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz#5e13fac887f08c44f76b0ccaf3370eb00fec9bb6"
integrity sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==
-"@esbuild/aix-ppc64@0.27.7":
- version "0.27.7"
- resolved "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz#82b74f92aa78d720b714162939fb248c90addf53"
- integrity sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==
-
-"@esbuild/aix-ppc64@0.28.1":
- version "0.28.1"
- resolved "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz#7a01a8d2ec2fbb2dac78adad09b0fa781e4082be"
- integrity sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==
-
-"@esbuild/android-arm64@0.27.7":
- version "0.27.7"
- resolved "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz#f78cb8a3121fc205a53285adb24972db385d185d"
- integrity sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==
-
-"@esbuild/android-arm64@0.28.1":
- version "0.28.1"
- resolved "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz#b540a27d14e4afd058496a4dbec4d3f414db110a"
- integrity sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==
-
-"@esbuild/android-arm@0.27.7":
- version "0.27.7"
- resolved "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz#593e10a1450bbfcac6cb321f61f468453bac209d"
- integrity sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==
-
-"@esbuild/android-arm@0.28.1":
- version "0.28.1"
- resolved "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz#704bd297de6d762de54eabbeafbf55f6756abe2f"
- integrity sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==
-
-"@esbuild/android-x64@0.27.7":
- version "0.27.7"
- resolved "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz#453143d073326033d2d22caf9e48de4bae274b07"
- integrity sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==
-
-"@esbuild/android-x64@0.28.1":
- version "0.28.1"
- resolved "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz#d1cb166d34b0fbf0fe8ab460a5594f24a378701e"
- integrity sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==
-
-"@esbuild/darwin-arm64@0.27.7":
- version "0.27.7"
- resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz#6f23000fb9b40b7e04b7d0606c0693bd0632f322"
- integrity sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==
-
-"@esbuild/darwin-arm64@0.28.1":
- version "0.28.1"
- resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz#1034b26457fc886368fe61bbd09f653f6afa8e54"
- integrity sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==
-
-"@esbuild/darwin-x64@0.27.7":
- version "0.27.7"
- resolved "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz#27393dd18bb1263c663979c5f1576e00c2d024be"
- integrity sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==
-
-"@esbuild/darwin-x64@0.28.1":
- version "0.28.1"
- resolved "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz#65556a432a1e4d72032d8218c1932fcca1a49772"
- integrity sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==
-
-"@esbuild/freebsd-arm64@0.27.7":
- version "0.27.7"
- resolved "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz#22e4638fa502d1c0027077324c97640e3adf3a62"
- integrity sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==
-
-"@esbuild/freebsd-arm64@0.28.1":
- version "0.28.1"
- resolved "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz#2e61e0592f9030d7e3dae18ee25ebc535918aef6"
- integrity sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==
-
-"@esbuild/freebsd-x64@0.27.7":
- version "0.27.7"
- resolved "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz#9224b8e4fea924ce2194e3efc3e9aebf822192d6"
- integrity sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==
-
-"@esbuild/freebsd-x64@0.28.1":
- version "0.28.1"
- resolved "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz#c95ec289959ef8079c4dca817a1e2c4be66b9bd3"
- integrity sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==
-
-"@esbuild/linux-arm64@0.27.7":
- version "0.27.7"
- resolved "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz#4f5d1c27527d817b35684ae21419e57c2bda0966"
- integrity sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==
-
-"@esbuild/linux-arm64@0.28.1":
- version "0.28.1"
- resolved "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz#40b22175dda06182f3ee8141186c5ff304c4a717"
- integrity sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==
-
-"@esbuild/linux-arm@0.27.7":
- version "0.27.7"
- resolved "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz#b9e9d070c8c1c0449cf12b20eac37d70a4595921"
- integrity sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==
-
-"@esbuild/linux-arm@0.28.1":
- version "0.28.1"
- resolved "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz#c09a0f67917592ac0de892a9be4d3814debd2a6c"
- integrity sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==
-
-"@esbuild/linux-ia32@0.27.7":
- version "0.27.7"
- resolved "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz#3f80fb696aa96051a94047f35c85b08b21c36f9e"
- integrity sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==
-
-"@esbuild/linux-ia32@0.28.1":
- version "0.28.1"
- resolved "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz#a580f9c676797833891e519fc7a1337c8afd8db3"
- integrity sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==
-
-"@esbuild/linux-loong64@0.27.7":
- version "0.27.7"
- resolved "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz#9be1f2c28210b13ebb4156221bba356fe1675205"
- integrity sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==
-
-"@esbuild/linux-loong64@0.28.1":
- version "0.28.1"
- resolved "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz#46452cf321dc7f9e91c2fa780a56bb56e79cd68b"
- integrity sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==
-
-"@esbuild/linux-mips64el@0.27.7":
- version "0.27.7"
- resolved "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz#4ab5ee67a3dfcbcb5e8fd7883dae6e735b1163b8"
- integrity sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==
-
-"@esbuild/linux-mips64el@0.28.1":
- version "0.28.1"
- resolved "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz#4211b3184dd6608f53dcb22e39f5d34ee08852c8"
- integrity sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==
-
-"@esbuild/linux-ppc64@0.27.7":
- version "0.27.7"
- resolved "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz#dac78c689f6499459c4321e5c15032c12307e7ea"
- integrity sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==
-
-"@esbuild/linux-ppc64@0.28.1":
- version "0.28.1"
- resolved "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz#697857c2a61cb9b0b6bb6652e40c1dc5e1ca8e5d"
- integrity sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==
-
-"@esbuild/linux-riscv64@0.27.7":
- version "0.27.7"
- resolved "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz#050f7d3b355c3a98308e935bc4d6325da91b0027"
- integrity sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==
-
-"@esbuild/linux-riscv64@0.28.1":
- version "0.28.1"
- resolved "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz#d192943eb146a40ac4c6497d0cf7be35b986bf08"
- integrity sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==
-
-"@esbuild/linux-s390x@0.27.7":
- version "0.27.7"
- resolved "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz#d61f715ce61d43fe5844ad0d8f463f88cbe4fef6"
- integrity sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==
-
-"@esbuild/linux-s390x@0.28.1":
- version "0.28.1"
- resolved "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz#acea0356da0e0ebc08f97cf7b9c2e401e1e648dc"
- integrity sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==
-
-"@esbuild/linux-x64@0.27.7":
- version "0.27.7"
- resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz#ca8e1aa478fc8209257bf3ac8f79c4dc2982f32a"
- integrity sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==
-
-"@esbuild/linux-x64@0.28.1":
- version "0.28.1"
- resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz#6f0c3ce0cb64c534b70c4c45ecb2c16d34e35dfd"
- integrity sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==
-
-"@esbuild/netbsd-arm64@0.27.7":
- version "0.27.7"
- resolved "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz#1650f2c1b948deeb3ef948f2fc30614723c09690"
- integrity sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==
-
-"@esbuild/netbsd-arm64@0.28.1":
- version "0.28.1"
- resolved "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz#8bcd77077a0dce3378b574fedb26d2a253b73d36"
- integrity sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==
-
-"@esbuild/netbsd-x64@0.27.7":
- version "0.27.7"
- resolved "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz#65772ab342c4b3319bf0705a211050aac1b6e320"
- integrity sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==
-
-"@esbuild/netbsd-x64@0.28.1":
- version "0.28.1"
- resolved "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz#e7fb2a01e99c830c94e6623cd9fefb4c8fb58347"
- integrity sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==
-
-"@esbuild/openbsd-arm64@0.27.7":
- version "0.27.7"
- resolved "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz#37ed7cfa66549d7955852fce37d0c3de4e715ea1"
- integrity sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==
-
-"@esbuild/openbsd-arm64@0.28.1":
- version "0.28.1"
- resolved "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz#c52909372db8b86e2c55e05a8940033b5660a3b2"
- integrity sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==
-
-"@esbuild/openbsd-x64@0.27.7":
- version "0.27.7"
- resolved "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz#01bf3d385855ef50cb33db7c4b52f957c34cd179"
- integrity sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==
-
-"@esbuild/openbsd-x64@0.28.1":
- version "0.28.1"
- resolved "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz#c427b9be5a64c262ff9a7eb70b5fbbaadf446c6c"
- integrity sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==
-
-"@esbuild/openharmony-arm64@0.27.7":
- version "0.27.7"
- resolved "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz#6c1f94b34086599aabda4eac8f638294b9877410"
- integrity sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==
-
-"@esbuild/openharmony-arm64@0.28.1":
- version "0.28.1"
- resolved "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz#dc9b147baca2e6c4b3c85571741ef4860a489097"
- integrity sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==
-
-"@esbuild/sunos-x64@0.27.7":
- version "0.27.7"
- resolved "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz#4b0dd17ae0a6941d2d0fd35a906392517071a90d"
- integrity sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==
-
-"@esbuild/sunos-x64@0.28.1":
- version "0.28.1"
- resolved "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz#ce866d12df13c15e4c99f073a3d466f6e0649b3a"
- integrity sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==
-
-"@esbuild/win32-arm64@0.27.7":
- version "0.27.7"
- resolved "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz#34193ab5565d6ff68ca928ac04be75102ccb2e77"
- integrity sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==
-
-"@esbuild/win32-arm64@0.28.1":
- version "0.28.1"
- resolved "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz#7468e3692d01d629d5941e5d83817bb80f9e39b4"
- integrity sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==
-
-"@esbuild/win32-ia32@0.27.7":
- version "0.27.7"
- resolved "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz#eb67f0e4482515d8c1894ede631c327a4da9fc4d"
- integrity sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==
-
-"@esbuild/win32-ia32@0.28.1":
- version "0.28.1"
- resolved "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz#a5bc0063fb2bcab6d0ed63f2a1537958bc269ec6"
- integrity sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==
-
-"@esbuild/win32-x64@0.27.7":
- version "0.27.7"
- resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz#8fe30b3088b89b4873c3a6cc87597ae3920c0a8b"
- integrity sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==
-
-"@esbuild/win32-x64@0.28.1":
- version "0.28.1"
- resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz#10064ee44f4347b90c9a02b446bbf80a91632b12"
- integrity sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==
+"@esbuild/aix-ppc64@0.28.2":
+ version "0.28.2"
+ resolved "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz#bf6e10303bcf2e7c686975fa52f937ec2728d8bc"
+ integrity sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==
+
+"@esbuild/android-arm64@0.28.2":
+ version "0.28.2"
+ resolved "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz#0c6246bc8d2c4d172aac2db3fb1190d72bd65504"
+ integrity sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==
+
+"@esbuild/android-arm@0.28.2":
+ version "0.28.2"
+ resolved "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz#2d84ece6a4e2684d92be26ee13d42757d831c381"
+ integrity sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==
+
+"@esbuild/android-x64@0.28.2":
+ version "0.28.2"
+ resolved "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz#fc38d4d6358d8dc1cf53f09f7589fe436eb64801"
+ integrity sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==
+
+"@esbuild/darwin-arm64@0.28.2":
+ version "0.28.2"
+ resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz#f83afeeac1d7dac01c7a2fd012b3e451a0591fcc"
+ integrity sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==
+
+"@esbuild/darwin-x64@0.28.2":
+ version "0.28.2"
+ resolved "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz#510147c055a795588dbbe14fd6b1b8ad0a2f30de"
+ integrity sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==
+
+"@esbuild/freebsd-arm64@0.28.2":
+ version "0.28.2"
+ resolved "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz#093b9200ecf0b115ba4e5e248a7485c9c5f8bd5e"
+ integrity sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==
+
+"@esbuild/freebsd-x64@0.28.2":
+ version "0.28.2"
+ resolved "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz#0be22b6df925d213e841ea87123af5df80b0faf7"
+ integrity sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==
+
+"@esbuild/linux-arm64@0.28.2":
+ version "0.28.2"
+ resolved "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz#1bdbc651cda9ba9995c53ed9c71ceaa65094762d"
+ integrity sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==
+
+"@esbuild/linux-arm@0.28.2":
+ version "0.28.2"
+ resolved "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz#beb12ad72b84f72d28488cc1b8ee9f7eb141d753"
+ integrity sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==
+
+"@esbuild/linux-ia32@0.28.2":
+ version "0.28.2"
+ resolved "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz#b81f9d55529b45c206a46a138214b1aa6879696b"
+ integrity sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==
+
+"@esbuild/linux-loong64@0.28.2":
+ version "0.28.2"
+ resolved "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz#598667241a04c99b76ed6ef940ac50038c419f98"
+ integrity sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==
+
+"@esbuild/linux-mips64el@0.28.2":
+ version "0.28.2"
+ resolved "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz#1c51eb9cea903f53d97b5af3b1841db70f5596ca"
+ integrity sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==
+
+"@esbuild/linux-ppc64@0.28.2":
+ version "0.28.2"
+ resolved "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz#63dd61f17ceb31a81227f413feac8a71bc2c51f2"
+ integrity sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==
+
+"@esbuild/linux-riscv64@0.28.2":
+ version "0.28.2"
+ resolved "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz#3763b08fde5cf25ab1facb8e7752edfe45fbfc27"
+ integrity sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==
+
+"@esbuild/linux-s390x@0.28.2":
+ version "0.28.2"
+ resolved "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz#1a137ff293a82906eb3176385bd7e8e0e5cfb7cb"
+ integrity sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==
+
+"@esbuild/linux-x64@0.28.2":
+ version "0.28.2"
+ resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz#268b36211c146ca54f8fe12c578a8d6ef8979485"
+ integrity sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==
+
+"@esbuild/netbsd-arm64@0.28.2":
+ version "0.28.2"
+ resolved "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz#22571ad951d62bb6accc82d8d1fad5c8c1ac0ba1"
+ integrity sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==
+
+"@esbuild/netbsd-x64@0.28.2":
+ version "0.28.2"
+ resolved "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz#42fcc57297eb0a0ca3f5fc475291f4c1a3f7c0de"
+ integrity sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==
+
+"@esbuild/openbsd-arm64@0.28.2":
+ version "0.28.2"
+ resolved "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz#9eb32af104ac3dacf4edca01f596664aab0c73ef"
+ integrity sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==
+
+"@esbuild/openbsd-x64@0.28.2":
+ version "0.28.2"
+ resolved "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz#febed2402d6088225e91f20fb4ce2522ad0a4efd"
+ integrity sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==
+
+"@esbuild/openharmony-arm64@0.28.2":
+ version "0.28.2"
+ resolved "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz#85641c3d466428bfbccea5f21c26836663fef5ce"
+ integrity sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==
+
+"@esbuild/sunos-x64@0.28.2":
+ version "0.28.2"
+ resolved "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz#a736f9d8962481045fc4c3e54f5479f22c870fb4"
+ integrity sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==
+
+"@esbuild/win32-arm64@0.28.2":
+ version "0.28.2"
+ resolved "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz#ee5ab40fad186201b652a33f8a5eb149e9e42532"
+ integrity sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==
+
+"@esbuild/win32-ia32@0.28.2":
+ version "0.28.2"
+ resolved "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz#c40d28a6d99a127da6711f2afd74b11cb63b06a7"
+ integrity sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==
+
+"@esbuild/win32-x64@0.28.2":
+ version "0.28.2"
+ resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz#b21affb804cc167c133d95f45b3a1dc1323b9a87"
+ integrity sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==
"@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1":
version "4.9.1"
@@ -2074,6 +1988,13 @@
"@emnapi/runtime" "^1.4.3"
"@tybys/wasm-util" "^0.10.0"
+"@napi-rs/wasm-runtime@^1.1.4", "@napi-rs/wasm-runtime@^1.1.6":
+ version "1.2.2"
+ resolved "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz#c70706532e5827c0932ca6bf43ee2c512f29c639"
+ integrity sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==
+ dependencies:
+ "@tybys/wasm-util" "^0.10.3"
+
"@next/env@16.2.11":
version "16.2.11"
resolved "https://registry.npmjs.org/@next/env/-/env-16.2.11.tgz#9dea1a225a99b1636e5a7166237db1f979b6c532"
@@ -2270,6 +2191,214 @@
resolved "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz#0acf32f470af2ceaf47f095cdecd40d68666efda"
integrity sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==
+"@oxc-parser/binding-android-arm-eabi@0.127.0":
+ version "0.127.0"
+ resolved "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.127.0.tgz#b75e796249ee22f632e40e942746c4bf648cee92"
+ integrity sha512-0LC7ye4hvqbIKxAzThzvswgHLFu2AURKzYLeSVvLdu2TBOYWQDmHnTqPLeA597BcUCxiLqLsS4CJ5uoI5WYWCQ==
+
+"@oxc-parser/binding-android-arm64@0.127.0":
+ version "0.127.0"
+ resolved "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.127.0.tgz#e264467fe39f80018f62fa0dae82db0b80260444"
+ integrity sha512-b5jtVTH6AU5CJXHNdj7Jj9IEiR9yVjjnwHzPJhGyHGPdcsZSzBCkS9GBbV33niRMvKthDwQRFRJfI4a+k4PvYg==
+
+"@oxc-parser/binding-darwin-arm64@0.127.0":
+ version "0.127.0"
+ resolved "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.127.0.tgz#0576d35109c00dcc6277200ba2eca7b47e07f1b1"
+ integrity sha512-obCE8B7ISKkJidjlhv9xRGJPOSDG2Yu6PRga9Ruaz35uintHxbp1Ki/Yc71wx4rj3Edrm0a1kzG1TAwit0wFpg==
+
+"@oxc-parser/binding-darwin-x64@0.127.0":
+ version "0.127.0"
+ resolved "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.127.0.tgz#efa1ba49075aa318ff540a1c2f8a442017417206"
+ integrity sha512-JL6Xb5IwPQT8rUzlpsX7E+AgfcdNklXNPFp8pjCQQ5MQOQo5rtEB2ui+3Hgg9Sn7Y9Egj6YOLLiHhLpdAe12Aw==
+
+"@oxc-parser/binding-freebsd-x64@0.127.0":
+ version "0.127.0"
+ resolved "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.127.0.tgz#817ba3c508d751d94d6e6fd86af69ddaa27da531"
+ integrity sha512-SDQ/3MQFw58fqQz3Z1PhSKFF3JoCF4gmlNjziDm8X02tTahCw0qJbd7FGPDKw1i4VTBZene9JPyC3mHtSvi+wA==
+
+"@oxc-parser/binding-linux-arm-gnueabihf@0.127.0":
+ version "0.127.0"
+ resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.127.0.tgz#b1c3096c654771998480316ef10d1e5d29edc79b"
+ integrity sha512-Av+D1MIqzV0YMGPT9we2SIZaMKD7Cxs4CvXSx/yxaWHewZjYEjScpOf5igc8IILASViw4WTnjlwUdI1KzVtDHQ==
+
+"@oxc-parser/binding-linux-arm-musleabihf@0.127.0":
+ version "0.127.0"
+ resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.127.0.tgz#c44a8f10e6c903685825aebf1289fc2086aed61e"
+ integrity sha512-Cs2fdJ8cPpFdeebj6p4dag8A4+56hPvZ0AhQQzlaLswGz1tz7bXt1nETLeorrM9+AMcWFFkqxcXwDGfTVidY8g==
+
+"@oxc-parser/binding-linux-arm64-gnu@0.127.0":
+ version "0.127.0"
+ resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.127.0.tgz#61c245abfab6f63045915b5c9cfa7d335ad7c440"
+ integrity sha512-qdOfTcT6SY8gsJrrV92uyEUyjqMGPpIB5JZUG6QN5dukYd+7/j0kX6MwK1DgQj39jtUYixxPiaRUiEN1+0CXgQ==
+
+"@oxc-parser/binding-linux-arm64-musl@0.127.0":
+ version "0.127.0"
+ resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.127.0.tgz#358bbd90e5c85b6c35125f5a6ff084e09b694c04"
+ integrity sha512-EoTCZneNFU/P2qrpEM+RHmQwt+CvDkyGESG6qhr7KaegXLZwePfbrkCDfAk8/rhxbDUVGsZILX+2tqPzFtoFWA==
+
+"@oxc-parser/binding-linux-ppc64-gnu@0.127.0":
+ version "0.127.0"
+ resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.127.0.tgz#b7ea7b51bf54db4c42819187f760e069d433dac3"
+ integrity sha512-zALjmZYgxFLHjXeudcDF0xFGNydTAtkAeXAr2EuC17ywCyFxcmQra4w0BMde0Yi/re4Bi4iwEoEXtYN7l6eBLQ==
+
+"@oxc-parser/binding-linux-riscv64-gnu@0.127.0":
+ version "0.127.0"
+ resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.127.0.tgz#3a3b10d160988df50bbbcd631c6af39de3dd451d"
+ integrity sha512-fPP8M6zQLS7Jz7o9d5ArUSuAuSK3e+WCYVrCpdzeCOejidtZExJ9tjhDrAd3HEPqARBCPmdpqxESPFqy44vkBQ==
+
+"@oxc-parser/binding-linux-riscv64-musl@0.127.0":
+ version "0.127.0"
+ resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.127.0.tgz#3787d37e1d0a15ee239f51610298500321b31730"
+ integrity sha512-7IcC4Ao02oGpfnjt+X/oF4U2mllo2qoSkw5xxiXNKL9MCTsTiAC6616beOuehdxGcnz1bRoPC1RQ2f1GQDdN+g==
+
+"@oxc-parser/binding-linux-s390x-gnu@0.127.0":
+ version "0.127.0"
+ resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.127.0.tgz#b71a16cbba115a4696498f9149bc54cc4e1df9cd"
+ integrity sha512-pbXIhiNFHoqWeqDNLiJ9JkpHz1IM9k4DXa66x+1GTWMG7iLxtkXgE53iiuKSXwmk3zIYmaPVfBvgcAhS583K4Q==
+
+"@oxc-parser/binding-linux-x64-gnu@0.127.0":
+ version "0.127.0"
+ resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.127.0.tgz#71527dd0284ba727d35a93c841c91192af3ebdec"
+ integrity sha512-MYCguB9RvBvlSd6gbuNI7QwiLoCCAlGnlRJFPrzLI6U1/9wkC/WK6LtBAUln55H1Ctqw45PWmqrobKoMhsYQzQ==
+
+"@oxc-parser/binding-linux-x64-musl@0.127.0":
+ version "0.127.0"
+ resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.127.0.tgz#16830afa4b001f349cebb93e12b278e72601cb3f"
+ integrity sha512-5eY0B/bxf1xIUxb4NOTvOI3KWtBQfPWYyKAzgcrCt0mDibSZygVpO1Pz8bkeiSZ5Jj9+M09dkggG3H8I5d0Uyg==
+
+"@oxc-parser/binding-openharmony-arm64@0.127.0":
+ version "0.127.0"
+ resolved "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.127.0.tgz#a41c71d249cb597dc357038eb1cbe3ce732453f8"
+ integrity sha512-Gld0ajrFTUXNtdw20fVBuTQx66FA75nIVg+//pPfR3sXkuABB4mTBhl3r9JNzrJpgW//qiwxf0nWXUWGJSL3UQ==
+
+"@oxc-parser/binding-wasm32-wasi@0.127.0":
+ version "0.127.0"
+ resolved "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.127.0.tgz#b1efcdb433b30ed4a3ad912fa03da3834bd4845d"
+ integrity sha512-T6KVD7rhLzFlwGRXMnxUFfkCZD8FHnb968wVXW1mXzgRFc5RNXOBY2mPPDZ77x5Ln76ltLMgtPg0cOkU1NSrEQ==
+ dependencies:
+ "@emnapi/core" "1.9.2"
+ "@emnapi/runtime" "1.9.2"
+ "@napi-rs/wasm-runtime" "^1.1.4"
+
+"@oxc-parser/binding-win32-arm64-msvc@0.127.0":
+ version "0.127.0"
+ resolved "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.127.0.tgz#b62b5e328126323d41ae1ee7adc95537c4c4423a"
+ integrity sha512-Ujvw4X+LD1CCGULcsQcvb4YNVoBGqt+JHgNNzGGaCImELiZLk477ifUH53gIbE7EKd933NdTi25JWEr9K2HwXw==
+
+"@oxc-parser/binding-win32-ia32-msvc@0.127.0":
+ version "0.127.0"
+ resolved "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.127.0.tgz#dac30de6971dbe63aa5722be9a4cc070fd3c650e"
+ integrity sha512-0cwxKO7KHQQQfo4Uf4B2SQrhgm+cJaP9OvFFhx52Tkg4bezsacu83GB2/In5bC415Ueeym+kXdnge/57rbSfTw==
+
+"@oxc-parser/binding-win32-x64-msvc@0.127.0":
+ version "0.127.0"
+ resolved "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.127.0.tgz#a2df879b0803f72b350a7567365cee5b8978edf0"
+ integrity sha512-rOrnSQSCbhI2kowr9XxE7m9a8oQXnBHjnS6j95LxxAnEZ0+Fz20WlRXG4ondQb+ejjt2KOsa65sE6++L6kUd+w==
+
+"@oxc-project/types@^0.127.0":
+ version "0.127.0"
+ resolved "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz#8374fcdfb4a641861218daa5700c447c00b66663"
+ integrity sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==
+
+"@oxc-resolver/binding-android-arm-eabi@11.24.2":
+ version "11.24.2"
+ resolved "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.24.2.tgz#5db3f0dcd659e1de664fb0ae912420839348309b"
+ integrity sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==
+
+"@oxc-resolver/binding-android-arm64@11.24.2":
+ version "11.24.2"
+ resolved "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.24.2.tgz#fec00a8bc89afa9a164bad79b23c14b8c86f95cf"
+ integrity sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==
+
+"@oxc-resolver/binding-darwin-arm64@11.24.2":
+ version "11.24.2"
+ resolved "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.24.2.tgz#b5e6e2c2bed585cbfd67b6e41eea7fce2a3da5f8"
+ integrity sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==
+
+"@oxc-resolver/binding-darwin-x64@11.24.2":
+ version "11.24.2"
+ resolved "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.24.2.tgz#db759d6fadac262a7da21b1bfb712b0199c9cd18"
+ integrity sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==
+
+"@oxc-resolver/binding-freebsd-x64@11.24.2":
+ version "11.24.2"
+ resolved "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.24.2.tgz#7fe0ab0725284aee9b6b6c45b81f0facf895fc62"
+ integrity sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==
+
+"@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2":
+ version "11.24.2"
+ resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.24.2.tgz#91a72b7987930c3acc5337237454ba0339f80333"
+ integrity sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==
+
+"@oxc-resolver/binding-linux-arm-musleabihf@11.24.2":
+ version "11.24.2"
+ resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.24.2.tgz#72d04ad7d2227fb3ac71aa7933794bfb88194b7b"
+ integrity sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==
+
+"@oxc-resolver/binding-linux-arm64-gnu@11.24.2":
+ version "11.24.2"
+ resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.24.2.tgz#b87faf59bde9ecff0b8288fe99a831eb7492f99d"
+ integrity sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==
+
+"@oxc-resolver/binding-linux-arm64-musl@11.24.2":
+ version "11.24.2"
+ resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.24.2.tgz#2da6551c561bf2f2bed34c312a99e18d184a9f07"
+ integrity sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==
+
+"@oxc-resolver/binding-linux-ppc64-gnu@11.24.2":
+ version "11.24.2"
+ resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.24.2.tgz#fda4558cc94e43fefdfa4f9b96391c30ec137adb"
+ integrity sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==
+
+"@oxc-resolver/binding-linux-riscv64-gnu@11.24.2":
+ version "11.24.2"
+ resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.24.2.tgz#2fe243a5112d221021a8b2fccd7b36aa96adc946"
+ integrity sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==
+
+"@oxc-resolver/binding-linux-riscv64-musl@11.24.2":
+ version "11.24.2"
+ resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.24.2.tgz#e95cb43856f7e9c4aa0afa438e043fdbf6c6d40d"
+ integrity sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==
+
+"@oxc-resolver/binding-linux-s390x-gnu@11.24.2":
+ version "11.24.2"
+ resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.24.2.tgz#8e3ca765e1af7ccab8a61adc96323810f9770535"
+ integrity sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==
+
+"@oxc-resolver/binding-linux-x64-gnu@11.24.2":
+ version "11.24.2"
+ resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.24.2.tgz#a2b14c1efc3252e705038bc23b7225a7cb434df2"
+ integrity sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==
+
+"@oxc-resolver/binding-linux-x64-musl@11.24.2":
+ version "11.24.2"
+ resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.24.2.tgz#d42f14a6a286b0a81871e2be6ee2eeb3d044afce"
+ integrity sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==
+
+"@oxc-resolver/binding-openharmony-arm64@11.24.2":
+ version "11.24.2"
+ resolved "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.24.2.tgz#1ce27bc037073b624c484e481ae61f4cc9b5cfbc"
+ integrity sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==
+
+"@oxc-resolver/binding-wasm32-wasi@11.24.2":
+ version "11.24.2"
+ resolved "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.24.2.tgz#9c818fd9512eed502da1972de1f8c9528b4c9d27"
+ integrity sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==
+ dependencies:
+ "@emnapi/core" "1.11.2"
+ "@emnapi/runtime" "1.11.2"
+ "@napi-rs/wasm-runtime" "^1.1.6"
+
+"@oxc-resolver/binding-win32-arm64-msvc@11.24.2":
+ version "11.24.2"
+ resolved "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.24.2.tgz#0e2bd6869ef554ffd3016594951f1f44f5f02617"
+ integrity sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==
+
+"@oxc-resolver/binding-win32-x64-msvc@11.24.2":
+ version "11.24.2"
+ resolved "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.24.2.tgz#d0649344fcd504dfaf7f3561a53617c38d98d789"
+ integrity sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==
+
"@polka/url@^1.0.0-next.24":
version "1.0.0-next.29"
resolved "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz#5a40109a1ab5f84d6fd8fc928b19f367cbe7e7b1"
@@ -2698,7 +2827,7 @@
resolved "https://registry.npmjs.org/@storybook/global/-/global-5.0.0.tgz#b793d34b94f572c1d7d9e0f44fac4e0dbc9572ed"
integrity sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==
-"@storybook/icons@^2.0.1":
+"@storybook/icons@^2.0.1", "@storybook/icons@^2.0.2":
version "2.1.0"
resolved "https://registry.npmjs.org/@storybook/icons/-/icons-2.1.0.tgz#edfc2450a39c5e780f28c6cbc49acd7bff59b41a"
integrity sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg==
@@ -2954,7 +3083,7 @@
resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.11.2.tgz#00409e743ac4eea9afe5b7708594d5fcebb00212"
integrity sha512-vTtpNt7mKCiZ1pwU9hfKPhpdVO2sVzFQsxoVBGtOSHxlrRRzYr8iQ2TlwbAcRYCcEiZ9ECAM8kBzH0v2+VzfKw==
-"@testing-library/dom@10.4.1":
+"@testing-library/dom@10.4.1", "@testing-library/dom@^10.4.1":
version "10.4.1"
resolved "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz#d444f8a889e9a46e9a3b4f3b88e0fcb3efb6cf95"
integrity sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==
@@ -2980,18 +3109,6 @@
picocolors "^1.1.1"
redent "^3.0.0"
-"@testing-library/jest-dom@^6.9.1":
- version "6.10.0"
- resolved "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.10.0.tgz#8a76841e94b72d55d09d2a34b9db9d75da9cbc08"
- integrity sha512-HQwu0KaB2zyT0iLzBL+8CLyZDL3KlZlZJ+2iyc9uCUnlJVskJU/UlPuVCyIPhtukjPQdT2QNoR5nCP5FqTmmDQ==
- dependencies:
- "@adobe/css-tools" "^4.4.0"
- aria-query "^5.0.0"
- css.escape "^1.5.1"
- dom-accessibility-api "^0.6.3"
- picocolors "^1.1.1"
- redent "^3.0.0"
-
"@testing-library/react@16.3.2":
version "16.3.2"
resolved "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz#672883b7acb8e775fc0492d9e9d25e06e89786d0"
@@ -3004,25 +3121,20 @@
resolved "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz#13e09a32d7a8b7060fe38304788ebf4197cd2149"
integrity sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==
-"@tiptap/core@^3.20.5":
- version "3.27.3"
- resolved "https://registry.npmjs.org/@tiptap/core/-/core-3.27.3.tgz#001d05642579b8c4727fe9acd71ce5f4900a508c"
- integrity sha512-TJj5929M96C1KlH796wS8MywfHDh49RhmakOyzyMMc9pFmRj9UXi1gj0TCXgsZtjEOG7B+m/DRvNOvnuvR9kmg==
-
"@tiptap/core@^3.29.2":
version "3.29.2"
resolved "https://registry.npmjs.org/@tiptap/core/-/core-3.29.2.tgz#90d24591a9e7fb450ffb95ed6a42f529348e3e9e"
integrity sha512-oKUkiPUB7noilVYxI9lNzUD4rX17sHub+PYjMfHMWHG9A3nvIy+FdePIVIIhThKWF7ijhr3eIqHY51Bn+GAFtw==
-"@tiptap/extension-blockquote@^3.20.5":
- version "3.20.5"
- resolved "https://registry.yarnpkg.com/@tiptap/extension-blockquote/-/extension-blockquote-3.20.5.tgz#c64341fce14154b8c2785ead168d395436f953e7"
- integrity sha512-0wU6H/MWWes0rGzgSW6MMU6YDs/3ofUDkqmqCqmb+Siu1ZD0bpzOYpBtujgOYDY8moB9+zCE3G9HSYGcmZxHew==
+"@tiptap/extension-blockquote@^3.29.2":
+ version "3.29.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.29.2.tgz#e654719cee5b039a5b4af97226e2652f592d4196"
+ integrity sha512-ca4OzKDh0yaxg2+Z56bC2QnWsNsFp2YMRfVig1PDXyMVFMNJpLcnhxgq/9btn+xYAlYrj8RymOeCTYREOR6Zjg==
-"@tiptap/extension-bold@^3.20.5":
- version "3.20.5"
- resolved "https://registry.yarnpkg.com/@tiptap/extension-bold/-/extension-bold-3.20.5.tgz#b40e8e43db3123c5dee9864931f7f9ad1b1e07dc"
- integrity sha512-hraiiWkF58n8Jy0Wl3OGwjCTrGWwZZxez/IlexrzKQ/nMFdjDpensZucWwu59zhAM9fqZwGSLDtCFuak03WKnA==
+"@tiptap/extension-bold@^3.29.2":
+ version "3.29.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.29.2.tgz#cd03d51096caa9135ccbb31c2bae778b5fac50df"
+ integrity sha512-elYbGxJsYnBb4leqrcjdIJuiG380BcOgN+UUzvOv+qEjfGVzHodFOMBl3qnmD6urYHNu5/qQK2S0qSSRXKCLNQ==
"@tiptap/extension-bubble-menu@^3.20.5":
version "3.22.3"
@@ -3031,119 +3143,119 @@
dependencies:
"@floating-ui/dom" "^1.0.0"
-"@tiptap/extension-bullet-list@^3.20.5":
- version "3.20.5"
- resolved "https://registry.yarnpkg.com/@tiptap/extension-bullet-list/-/extension-bullet-list-3.20.5.tgz#dc53ab798a48c3aaf175752899f04cad2abc8ef3"
- integrity sha512-MT3321R6F8AoVUEMJ5RiI0PQMenwvtmrSXoO1ehPCWq5TrSJLyXeZMJvZU+1CgfXk4XQU70RN78ib5+Zg+/FCg==
+"@tiptap/extension-bullet-list@^3.29.2":
+ version "3.29.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.29.2.tgz#c25a6023c7ee13e35f76ffcc4fd436415e9fa0b7"
+ integrity sha512-3bWcCUPbCHv0XttlMdnAtXLNYWx2pblByMgxmGsaP9FU0QnslGXty6A6gHCqI33ygRg1vrA6U5Wtpwbi5aKu5g==
-"@tiptap/extension-code-block@^3.20.5":
- version "3.20.5"
- resolved "https://registry.yarnpkg.com/@tiptap/extension-code-block/-/extension-code-block-3.20.5.tgz#96daefd431f37a87eac33095d020937dd438fe6c"
- integrity sha512-0YZnqfqZ1IjzKBM4aezw8j3LZWJFEfs4+mbizHNlnZSYpKzpESYLeaLWGO5SpqF9Z8tmYmSoCaf0fqi5LwgdIA==
+"@tiptap/extension-code-block@^3.29.2":
+ version "3.29.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.29.2.tgz#fd91f2475d0c9e289ac98cc3210f86d869af4e16"
+ integrity sha512-w153ct8g6dLiPTdXQ6SOIMxX4SEo5Q50AmjdEEEcJ7ZcYUcde/ScSskLHfOYmyt5ZFAiyEwr121+pux+p3/oAQ==
-"@tiptap/extension-code@^3.20.5":
- version "3.20.5"
- resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-3.20.5.tgz#c6c93fcb553ddb9e185316a4876f79b7d5d21171"
- integrity sha512-jBZK/CfdMvg1gkNK/zNAk02IExpBPwUfNLRPiJvGhReL2Q73naKxZGQGp+5Lej9VaeFB70UKuRma/iIzuZbgsA==
+"@tiptap/extension-code@^3.29.2":
+ version "3.29.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.29.2.tgz#89a1398ae5e0bebcd7833c0e73621f11ccaa4a44"
+ integrity sha512-c6W5UGuB7WNLpYocsgRzpO2OOTI4QjaI9jjHRMuty9z+s9DtaYM/HrRLNwVh6MopkHb+i/89Wkv8gCS34fftig==
-"@tiptap/extension-document@^3.20.5":
- version "3.20.5"
- resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-3.20.5.tgz#24a15654057872db469da6b91584875dcda070ea"
- integrity sha512-BpNGHtOTAjjs/6QbkrafMTlaJqb0gsPngFzd5rB0csxx7rYRE9nIEY+oZ44qMw161+2YB4u20L17SX2mUJANBw==
+"@tiptap/extension-document@^3.29.2":
+ version "3.29.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.29.2.tgz#215d692d4b5b9d7bbc8db8f9b9d4221f2a57c9a1"
+ integrity sha512-YUamvefLnsqu6124GavVTI7nqcFlQJ12ROB0oSwG69eSBZYNjg1tIs05LFrBxkwf4Xgqd6YzfJ9+FeG428RvzQ==
-"@tiptap/extension-dropcursor@^3.20.5":
- version "3.20.5"
- resolved "https://registry.yarnpkg.com/@tiptap/extension-dropcursor/-/extension-dropcursor-3.20.5.tgz#ea810297825b009c357559e66f5fd76e91e8c940"
- integrity sha512-/lDG9OjvAv0ynmgFH17mt/GUeGT5bqu0iPW8JMgaRqlKawk+uUIv5SF5WkXS4SwxXih+hXdPEQD3PWZnxlQxAQ==
+"@tiptap/extension-dropcursor@^3.29.2":
+ version "3.29.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.29.2.tgz#faa11b6d0d9312e964fcf0087fdc985ca57bc344"
+ integrity sha512-KKno7cU9r1HdR48CRrsDu69/1UjZdoslq/UcE+Kx+tdhAv/aljXMkRSNzGMrBNOBDmHRgS1+58zm21WQWdQzwA==
"@tiptap/extension-floating-menu@^3.20.5":
version "3.22.3"
resolved "https://registry.yarnpkg.com/@tiptap/extension-floating-menu/-/extension-floating-menu-3.22.3.tgz#c9a911b7784cb45d6f8e7260d77bf2015066e5a4"
integrity sha512-0f8b4KZ3XKai8GXWseIYJGdOfQr3evtFbBo3U08zy2aYzMMXWG0zEF7qe5/oiYp2aZ95edjjITnEceviTsZkIg==
-"@tiptap/extension-gapcursor@^3.20.5":
- version "3.20.5"
- resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-3.20.5.tgz#0fe2ffb1d7669fc4f5541a0c66342da4107b08f8"
- integrity sha512-H+bRr+mqU/DQq1vfoMlppK1o+RbfSKYBMIcAMHWOez+C96MWfj5bhooVU2HLtl4XGmQxKGr3oEOCKDPdtRNThg==
+"@tiptap/extension-gapcursor@^3.29.2":
+ version "3.29.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.29.2.tgz#c21fd638d9e4923f0260a7a5cfd4a38cf7c05216"
+ integrity sha512-8Q39UR4/Tit759IeW9xZIe3NMwN11GsuA3FLheDyyGn7RrW02HD3HhUDlazE54Ki4HoosjFmChPlN4Ik2ubdRQ==
-"@tiptap/extension-hard-break@^3.20.5":
- version "3.20.5"
- resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-3.20.5.tgz#79a4409e81a35c9f8b664616a9b2ecbd4cb81953"
- integrity sha512-+aILNDO7BsXf0IJ4/0BYh570usFK3Q1t/ZQd8zhHuO2ATeWeDVu1x2F+ouFS4X8fmoCcioMzw15aoz93GET6kQ==
+"@tiptap/extension-hard-break@^3.29.2":
+ version "3.29.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.29.2.tgz#334ebaa9c0d2eed7df9037524eb0f6b15732fb1d"
+ integrity sha512-eUW3LN3fq8rXnjEUeI3D2QONYdLsU3yYQm4jxlErs2h4cfwrFjgf19VSUFVmm6LrFbbQ0OnDVPeVLL6iOwDw2w==
-"@tiptap/extension-heading@^3.20.5", "@tiptap/extension-heading@^3.27.3":
- version "3.27.3"
- resolved "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.27.3.tgz#8f3b0f0f0afd172b6879fad265d43761bc8caa4b"
- integrity sha512-QHXnsNic6iId8pnsFZ8z4PkX5L+HCHa/D7rAi3nNWtPlSIAOxo4nKrALcB5/tHmY+XL8kEXKH3nsNLNEDLCYPg==
+"@tiptap/extension-heading@^3.27.3", "@tiptap/extension-heading@^3.29.2":
+ version "3.29.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.29.2.tgz#a7b392d7dd2cd463d9eda7556aaf0bb297795ad9"
+ integrity sha512-6W4aIy70Mh7BNlbG9zZ5FBLhJhU2UUEzgZJ/jwYSCcB30o8McLxJSEjhtoHiX8R78Ah2/JzBGvIe5olZlbeE4A==
-"@tiptap/extension-horizontal-rule@^3.20.5":
- version "3.20.5"
- resolved "https://registry.yarnpkg.com/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.20.5.tgz#c21b2c7405f4aad7b507e36cc3394aba51ea2253"
- integrity sha512-4UtpUHg8cRzxWjJUGtni5VnXYbhsO7ygf1H1pr4Rv63XMBg9lfYDeSwByIuVy9biEFP7eGEFnezzb5Zlh1btmQ==
+"@tiptap/extension-horizontal-rule@^3.29.2":
+ version "3.29.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.29.2.tgz#a2afe200c29f9355fa1a79c96fc73bef016bbbd2"
+ integrity sha512-8/ZPzbB9X85Mc9/7xVLZupQKBr2UVcQTGr512xtqMW+XkCQRHCph46tRo828YE13IMWI5fWn/FaNCqXG9cULSw==
-"@tiptap/extension-italic@^3.20.5":
- version "3.20.5"
- resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-3.20.5.tgz#c53436f05968b16eda6b8e0efbaebaf3f4587e3b"
- integrity sha512-7bZCgdJVTvhR5vSmNgFQbGvgRoC6m26KcUpHqWiKA95kLL5Wk4YlMCIqdiDpvJ1eakeFEvDcGZvFLg5+1NiQ+w==
+"@tiptap/extension-italic@^3.29.2":
+ version "3.29.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.29.2.tgz#3d169fdcc34a603304f14946c6f638ddaadc9af8"
+ integrity sha512-iH63V/5wsaMnY4Jz0+meaAGhaec4AiOzOduzl6ZZr5IyGhZ1kthyW84ELt0dyLI3hNceAUhaNWc+I7+vX0aoXA==
-"@tiptap/extension-link@^3.20.5":
- version "3.20.5"
- resolved "https://registry.yarnpkg.com/@tiptap/extension-link/-/extension-link-3.20.5.tgz#fbed2a1b82b0e9a73a2628782408135fbe698575"
- integrity sha512-0PukrSYnHX2CrGSThlKfQWxpPWmL7QAvdpDUraKknGvVNSH7tUjchTshy5JdLrn/SQAU92REowRCB6zzCNEFjA==
+"@tiptap/extension-link@^3.29.2":
+ version "3.29.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.29.2.tgz#a91ff8625cc609e9ba0bfb5d5052b4168436c4f5"
+ integrity sha512-DcVer5SqrexKCEP6Ip1UPxJUMvcRCCItSv0wxoGytanrimBh2smvcg6X0DWnjlsi5H0updhyl+atYCmmQXUIXA==
dependencies:
- linkifyjs "^4.3.2"
+ linkifyjs "^4.3.3"
-"@tiptap/extension-list-item@^3.20.5":
- version "3.20.5"
- resolved "https://registry.yarnpkg.com/@tiptap/extension-list-item/-/extension-list-item-3.20.5.tgz#3bbe5c8cc2a5f6ad7900803338b41a29e33409ba"
- integrity sha512-pFJCGLIDEin1Xn6B3ctbrZvtYyALARE56ya4SmaNfnl+Hww5MfkRR40obbwYD3byA1yOpr+bECy+I2clQqzTDw==
+"@tiptap/extension-list-item@^3.29.2":
+ version "3.29.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.29.2.tgz#febddb22badf0facebf3325b915f65f4a7c8f697"
+ integrity sha512-s8vBVHHFT0Qpu7CzAZ7S1kYmSiVaDvUNvMNcZUWnxj6VPfiwmx0eXd9FsjePrRCMoM5tFmPnhFTHDxY3D/eZeQ==
-"@tiptap/extension-list-keymap@^3.20.5":
- version "3.20.5"
- resolved "https://registry.yarnpkg.com/@tiptap/extension-list-keymap/-/extension-list-keymap-3.20.5.tgz#272077f1e1f55b4306583fcfa81d0208f8814a71"
- integrity sha512-rmrQgOrUb0jKtFzVUfT0UNEST2sGM2Ve4lOl+1luh66RW6TD+gvgMk/qo12/Kffl9PUiqz8oYfk2qXCwFb6Bug==
+"@tiptap/extension-list-keymap@^3.29.2":
+ version "3.29.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.29.2.tgz#cdb29a4ce7fdff9f986df713501272d9fc643930"
+ integrity sha512-R+3k8OLnxdCH7Xy9ieOwUt5m2Je74u8mikothGmsYVO2Zyq48fIbmZ+X6RBPCu7DBOI2FIUhHEFbKQeDWvDNmA==
-"@tiptap/extension-list@^3.20.5":
- version "3.20.5"
- resolved "https://registry.yarnpkg.com/@tiptap/extension-list/-/extension-list-3.20.5.tgz#98acebb38d051790e97ebabcb93327ac8ecd6909"
- integrity sha512-s+Y8Q7Orq+WQiwgFB/VPMYZe+6EAR2F69xCpvOynlzTInLO4cF6QpXomuGEYAZxLHe8ZBmeIaR7y8MH/OgjrDw==
+"@tiptap/extension-list@^3.29.2":
+ version "3.29.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.29.2.tgz#53324e55f1c89efcf450b0c92f84361ced15e2ab"
+ integrity sha512-WPZ9BHAPT6QeIm1vdVkuoOWvy9a8/EZeJwV2VhU8LXyTAttvzyj4rsbbHyJWvYWlUSTt/QF2AZ2zhKo7u1w3/A==
-"@tiptap/extension-ordered-list@^3.20.5":
- version "3.20.5"
- resolved "https://registry.yarnpkg.com/@tiptap/extension-ordered-list/-/extension-ordered-list-3.20.5.tgz#c5b5abff89ec2b0bd82a8c62828dc317832a0e66"
- integrity sha512-Y/RIE3AxUNYAFKGMM5FLlTVKxxBvOh4JlLp/qYsOCY2nJdH0Jopl2FpfBYc4xoJwFSk8BELJ4Ow0adcYb15ksg==
+"@tiptap/extension-ordered-list@^3.29.2":
+ version "3.29.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.29.2.tgz#2053ddaef243e455bb1267fbe81c337487fa4771"
+ integrity sha512-ndCunC+UsYOpkOtL7vGnDz21UNa45WUlcO9wMT1fbuYow2QnRhsuMlCWENXI52YPPARWuQ0RDgN7q6TaxPERBg==
-"@tiptap/extension-paragraph@^3.20.5":
- version "3.20.5"
- resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-3.20.5.tgz#4344d623213bbec5a025b8c5cb751979a1f3b293"
- integrity sha512-mwuhwmff67IpGfOViyRvUC14IlkpsOnB+hSExVnq5+hCntjt/Cr2Z8GGOgzHeIM2FIS0UqX9Lv/b6ttUg4+Now==
+"@tiptap/extension-paragraph@^3.29.2":
+ version "3.29.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.29.2.tgz#da8de494530843891c399d18ab15ea2b2f5e2ffb"
+ integrity sha512-7qJj5YTr11vvjNgjDN1ypOfwTovc0QOCYcit/rskeuVgnmQZOZQzC/BbyKLLG7UGnpRLemU/mEGbW9pAqjAXkQ==
-"@tiptap/extension-strike@^3.20.5":
- version "3.20.5"
- resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-3.20.5.tgz#a3689fc17ad89a23c88f11b27c7f53896caa54f3"
- integrity sha512-uwhvmfS4ciGYJRLUg0AHbWsprMCwyWVWd2RXOLRm0ZQeWkvzonPXZhJvzIhIgsFkPLj/dsN5t0+LdiK4UQMnyA==
+"@tiptap/extension-strike@^3.29.2":
+ version "3.29.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.29.2.tgz#fe17b6140955e8f45c7ad32c66539891be934441"
+ integrity sha512-aEvLAbddUQZ+FukCreV3q4G2HfNI+odE7E9U+wbq6XsSWKyo8/pDu1muz+TFKNre4blSMOQ3JQmw5UeHDKy+fg==
"@tiptap/extension-table@^3.20.5":
version "3.20.5"
resolved "https://registry.yarnpkg.com/@tiptap/extension-table/-/extension-table-3.20.5.tgz#bac3d76e1c5fc8a4672f1495532a934651f50ce8"
integrity sha512-YvTB5OfGqjqHqutkSyywplouFvJwlsDTpZAjtAh5TzKfOan42aiVepmHVpteoQP6LH0mSjw69RndFMIYhIGmSQ==
-"@tiptap/extension-text@^3.20.5":
- version "3.20.5"
- resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-3.20.5.tgz#48e1cb2ee149eef7857b6a3131a32c341f572f05"
- integrity sha512-DMa9g5cH2d/Gx1KXtV7txTxaa6FBqgG8glmfug+N93VMb8sEZR1Yu1az++yAep4SGGq9GWIGZCUS3H6W66et6Q==
+"@tiptap/extension-text@^3.29.2":
+ version "3.29.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.29.2.tgz#7f590043b9044bd7cbc478c65e211bc3f2538335"
+ integrity sha512-Ubko45JWWHe8glBt2PiGNF8hcbys/JNalFhiR7Y1X4iOOtAxAKJJxh3+eq+//NTlGuBPdWGp7zw8EEUp7anjKA==
-"@tiptap/extension-underline@^3.20.5":
- version "3.20.5"
- resolved "https://registry.yarnpkg.com/@tiptap/extension-underline/-/extension-underline-3.20.5.tgz#97321f4405b303f9d54d2716dec6ab5bf9bc493e"
- integrity sha512-HMhr5KIAqZsEhlN8RxKHr/ql1a8OvBa9fLf69IwUVFolBcDExHWUtaEV/axYVRQJvvIy2oKGJxlJWDZ4hkotHQ==
+"@tiptap/extension-underline@^3.29.2":
+ version "3.29.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.29.2.tgz#e3545cc020608fcb7bdde5f1136d3ee8e330cb4e"
+ integrity sha512-K7XwH/xS/5AIREWQ00VTEf/W5U0olp7j6wwit7cdd/8nHv6h6AGr1+iEApHKoLXWQZLfGQKzJlT9W61LAl+fHA==
-"@tiptap/extensions@^3.20.5":
- version "3.20.5"
- resolved "https://registry.yarnpkg.com/@tiptap/extensions/-/extensions-3.20.5.tgz#d2460b110deed4a71aca4c0d37816fc8845b22ad"
- integrity sha512-c4am6SznqfMnbUNSh4MvufiD7cMLdqL1BArok22uBgSWkS1sB9RVBYe8+x0jrOkk0UPEVlzDHbQ+nU+WmIyS2Q==
+"@tiptap/extensions@^3.29.2":
+ version "3.29.2"
+ resolved "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.29.2.tgz#207c6ed79db1a5baec13f3fd8653b437fca2c8b6"
+ integrity sha512-BCz+FCAChSYtUe4BFj97HEO+nSK+J7GxbJgZG4Hg7DT/gI+hRyeNndU8efiQAx3WGdzsFi3UxRpcF1tTQM7iMQ==
-"@tiptap/pm@^3.20.5", "@tiptap/pm@^3.29.2":
+"@tiptap/pm@^3.29.2":
version "3.29.2"
resolved "https://registry.npmjs.org/@tiptap/pm/-/pm-3.29.2.tgz#de461c6f8986ef807082f467cde2d8fdf1cce4bc"
integrity sha512-GCOme7xHaS+DSoaA4CDcAD3l6JyBlvZhvCyfsy2Vp6j8tEoBkZWio7soYVosmlyn7zq8/64VeFZP5s47yfG7fQ==
@@ -3174,35 +3286,35 @@
"@tiptap/extension-bubble-menu" "^3.20.5"
"@tiptap/extension-floating-menu" "^3.20.5"
-"@tiptap/starter-kit@^3.20.5":
- version "3.20.5"
- resolved "https://registry.yarnpkg.com/@tiptap/starter-kit/-/starter-kit-3.20.5.tgz#67a6c7ed20b81f5746fc0552f4efc02bc6fbf684"
- integrity sha512-L5E2TCGK0EiwmGIlwMsiwNTW1TLbfPF1Dsji4bSKRJnPbccZIMCB6qdId8v/Z+QGm85NVcBHeruQrDlKDddXBA==
- dependencies:
- "@tiptap/core" "^3.20.5"
- "@tiptap/extension-blockquote" "^3.20.5"
- "@tiptap/extension-bold" "^3.20.5"
- "@tiptap/extension-bullet-list" "^3.20.5"
- "@tiptap/extension-code" "^3.20.5"
- "@tiptap/extension-code-block" "^3.20.5"
- "@tiptap/extension-document" "^3.20.5"
- "@tiptap/extension-dropcursor" "^3.20.5"
- "@tiptap/extension-gapcursor" "^3.20.5"
- "@tiptap/extension-hard-break" "^3.20.5"
- "@tiptap/extension-heading" "^3.20.5"
- "@tiptap/extension-horizontal-rule" "^3.20.5"
- "@tiptap/extension-italic" "^3.20.5"
- "@tiptap/extension-link" "^3.20.5"
- "@tiptap/extension-list" "^3.20.5"
- "@tiptap/extension-list-item" "^3.20.5"
- "@tiptap/extension-list-keymap" "^3.20.5"
- "@tiptap/extension-ordered-list" "^3.20.5"
- "@tiptap/extension-paragraph" "^3.20.5"
- "@tiptap/extension-strike" "^3.20.5"
- "@tiptap/extension-text" "^3.20.5"
- "@tiptap/extension-underline" "^3.20.5"
- "@tiptap/extensions" "^3.20.5"
- "@tiptap/pm" "^3.20.5"
+"@tiptap/starter-kit@^3.29.2":
+ version "3.29.2"
+ resolved "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.29.2.tgz#9e61bdfc628923e9c1cd5a6ed80a1d884ae1855c"
+ integrity sha512-oTu0tysiqk4zgjEtxRHjAQgxUKaAevZwueOWwSWubHdokqp7SpcbE5n9USJv89HKuTUDm3GjnQH6q8HNn/2DsA==
+ dependencies:
+ "@tiptap/core" "^3.29.2"
+ "@tiptap/extension-blockquote" "^3.29.2"
+ "@tiptap/extension-bold" "^3.29.2"
+ "@tiptap/extension-bullet-list" "^3.29.2"
+ "@tiptap/extension-code" "^3.29.2"
+ "@tiptap/extension-code-block" "^3.29.2"
+ "@tiptap/extension-document" "^3.29.2"
+ "@tiptap/extension-dropcursor" "^3.29.2"
+ "@tiptap/extension-gapcursor" "^3.29.2"
+ "@tiptap/extension-hard-break" "^3.29.2"
+ "@tiptap/extension-heading" "^3.29.2"
+ "@tiptap/extension-horizontal-rule" "^3.29.2"
+ "@tiptap/extension-italic" "^3.29.2"
+ "@tiptap/extension-link" "^3.29.2"
+ "@tiptap/extension-list" "^3.29.2"
+ "@tiptap/extension-list-item" "^3.29.2"
+ "@tiptap/extension-list-keymap" "^3.29.2"
+ "@tiptap/extension-ordered-list" "^3.29.2"
+ "@tiptap/extension-paragraph" "^3.29.2"
+ "@tiptap/extension-strike" "^3.29.2"
+ "@tiptap/extension-text" "^3.29.2"
+ "@tiptap/extension-underline" "^3.29.2"
+ "@tiptap/extensions" "^3.29.2"
+ "@tiptap/pm" "^3.29.2"
"@tybys/wasm-util@^0.10.0":
version "0.10.1"
@@ -3211,6 +3323,13 @@
dependencies:
tslib "^2.4.0"
+"@tybys/wasm-util@^0.10.3":
+ version "0.10.3"
+ resolved "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz#015cba9e9dd47ce14d03d2a8c5d547bfb169665d"
+ integrity sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==
+ dependencies:
+ tslib "^2.4.0"
+
"@types/aria-query@^5.0.1":
version "5.0.4"
resolved "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz#1a31c3d378850d2778dabb6374d036dcba4ba708"
@@ -4051,9 +4170,9 @@ asynckit@^0.4.0:
resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==
-attr-accept@^2.2.4:
+attr-accept@^2.2.5:
version "2.2.5"
- resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.2.5.tgz#d7061d958e6d4f97bf8665c68b75851a0713ab5e"
+ resolved "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz#d7061d958e6d4f97bf8665c68b75851a0713ab5e"
integrity sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==
available-typed-arrays@^1.0.7:
@@ -5189,69 +5308,37 @@ es-toolkit@^1.39.3:
resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.45.1.tgz#21b28b2bd43178fd4c9c937c445d5bcaccce907b"
integrity sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==
-"esbuild@^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0":
- version "0.27.7"
- resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz#bcadce22b2f3fd76f257e3a64f83a64986fea11f"
- integrity sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==
+"esbuild@^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0", "esbuild@^0.27.0 || ^0.28.0":
+ version "0.28.2"
+ resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz#0f43bd1bad955b72d24e2261e3abe5957ccf0816"
+ integrity sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==
optionalDependencies:
- "@esbuild/aix-ppc64" "0.27.7"
- "@esbuild/android-arm" "0.27.7"
- "@esbuild/android-arm64" "0.27.7"
- "@esbuild/android-x64" "0.27.7"
- "@esbuild/darwin-arm64" "0.27.7"
- "@esbuild/darwin-x64" "0.27.7"
- "@esbuild/freebsd-arm64" "0.27.7"
- "@esbuild/freebsd-x64" "0.27.7"
- "@esbuild/linux-arm" "0.27.7"
- "@esbuild/linux-arm64" "0.27.7"
- "@esbuild/linux-ia32" "0.27.7"
- "@esbuild/linux-loong64" "0.27.7"
- "@esbuild/linux-mips64el" "0.27.7"
- "@esbuild/linux-ppc64" "0.27.7"
- "@esbuild/linux-riscv64" "0.27.7"
- "@esbuild/linux-s390x" "0.27.7"
- "@esbuild/linux-x64" "0.27.7"
- "@esbuild/netbsd-arm64" "0.27.7"
- "@esbuild/netbsd-x64" "0.27.7"
- "@esbuild/openbsd-arm64" "0.27.7"
- "@esbuild/openbsd-x64" "0.27.7"
- "@esbuild/openharmony-arm64" "0.27.7"
- "@esbuild/sunos-x64" "0.27.7"
- "@esbuild/win32-arm64" "0.27.7"
- "@esbuild/win32-ia32" "0.27.7"
- "@esbuild/win32-x64" "0.27.7"
-
-"esbuild@^0.27.0 || ^0.28.0":
- version "0.28.1"
- resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz#ef45b4634c9c9d97a296aea4114a5f9840f95578"
- integrity sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==
- optionalDependencies:
- "@esbuild/aix-ppc64" "0.28.1"
- "@esbuild/android-arm" "0.28.1"
- "@esbuild/android-arm64" "0.28.1"
- "@esbuild/android-x64" "0.28.1"
- "@esbuild/darwin-arm64" "0.28.1"
- "@esbuild/darwin-x64" "0.28.1"
- "@esbuild/freebsd-arm64" "0.28.1"
- "@esbuild/freebsd-x64" "0.28.1"
- "@esbuild/linux-arm" "0.28.1"
- "@esbuild/linux-arm64" "0.28.1"
- "@esbuild/linux-ia32" "0.28.1"
- "@esbuild/linux-loong64" "0.28.1"
- "@esbuild/linux-mips64el" "0.28.1"
- "@esbuild/linux-ppc64" "0.28.1"
- "@esbuild/linux-riscv64" "0.28.1"
- "@esbuild/linux-s390x" "0.28.1"
- "@esbuild/linux-x64" "0.28.1"
- "@esbuild/netbsd-arm64" "0.28.1"
- "@esbuild/netbsd-x64" "0.28.1"
- "@esbuild/openbsd-arm64" "0.28.1"
- "@esbuild/openbsd-x64" "0.28.1"
- "@esbuild/openharmony-arm64" "0.28.1"
- "@esbuild/sunos-x64" "0.28.1"
- "@esbuild/win32-arm64" "0.28.1"
- "@esbuild/win32-ia32" "0.28.1"
- "@esbuild/win32-x64" "0.28.1"
+ "@esbuild/aix-ppc64" "0.28.2"
+ "@esbuild/android-arm" "0.28.2"
+ "@esbuild/android-arm64" "0.28.2"
+ "@esbuild/android-x64" "0.28.2"
+ "@esbuild/darwin-arm64" "0.28.2"
+ "@esbuild/darwin-x64" "0.28.2"
+ "@esbuild/freebsd-arm64" "0.28.2"
+ "@esbuild/freebsd-x64" "0.28.2"
+ "@esbuild/linux-arm" "0.28.2"
+ "@esbuild/linux-arm64" "0.28.2"
+ "@esbuild/linux-ia32" "0.28.2"
+ "@esbuild/linux-loong64" "0.28.2"
+ "@esbuild/linux-mips64el" "0.28.2"
+ "@esbuild/linux-ppc64" "0.28.2"
+ "@esbuild/linux-riscv64" "0.28.2"
+ "@esbuild/linux-s390x" "0.28.2"
+ "@esbuild/linux-x64" "0.28.2"
+ "@esbuild/netbsd-arm64" "0.28.2"
+ "@esbuild/netbsd-x64" "0.28.2"
+ "@esbuild/openbsd-arm64" "0.28.2"
+ "@esbuild/openbsd-x64" "0.28.2"
+ "@esbuild/openharmony-arm64" "0.28.2"
+ "@esbuild/sunos-x64" "0.28.2"
+ "@esbuild/win32-arm64" "0.28.2"
+ "@esbuild/win32-ia32" "0.28.2"
+ "@esbuild/win32-x64" "0.28.2"
escalade@^3.1.1, escalade@^3.2.0:
version "3.2.0"
@@ -5634,12 +5721,10 @@ file-entry-cache@^8.0.0:
dependencies:
flat-cache "^4.0.0"
-file-selector@^2.1.0:
- version "2.1.2"
- resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-2.1.2.tgz#fe7c7ee9e550952dfbc863d73b14dc740d7de8b4"
- integrity sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==
- dependencies:
- tslib "^2.7.0"
+file-selector@^4.1.0:
+ version "4.1.0"
+ resolved "https://registry.npmjs.org/file-selector/-/file-selector-4.1.0.tgz#8759e5b0ef030c5cee36ea6f4b66cd9b23a40d86"
+ integrity sha512-Io1mP8CI3zec5Bxy3P3TxdrKnt35Cm8vNIHnZsvyj43l4YFjD4NRInBp240S5bDJQ0EP1jnh7nCAwXsO818OCg==
fill-range@^7.1.1:
version "7.1.1"
@@ -6650,6 +6735,11 @@ json5@^2.2.2, json5@^2.2.3:
resolved "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283"
integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
+jsonc-parser@^3.3.1:
+ version "3.3.1"
+ resolved "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz#f2a524b4f7fd11e3d791e559977ad60b98b798b4"
+ integrity sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==
+
jspdf-autotable@^5.0.8:
version "5.0.8"
resolved "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-5.0.8.tgz#b010dab34caf5eff60bbcd09a36d0608dc0a84ae"
@@ -6734,10 +6824,10 @@ lines-and-columns@^1.1.6:
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
-linkifyjs@^4.3.2:
- version "4.3.2"
- resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-4.3.2.tgz#d97eb45419aabf97ceb4b05a7adeb7b8c8ade2b1"
- integrity sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==
+linkifyjs@^4.3.3:
+ version "4.3.3"
+ resolved "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz#da08f0eeb4d89a24541d09591fbdcc211eb8fef0"
+ integrity sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==
locate-path@^6.0.0:
version "6.0.0"
@@ -7694,6 +7784,59 @@ own-keys@^1.0.1:
object-keys "^1.1.1"
safe-push-apply "^1.0.0"
+oxc-parser@^0.127.0:
+ version "0.127.0"
+ resolved "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.127.0.tgz#bb14600f5c59fb6b1fbac0ab6ff2cd3495a6df1d"
+ integrity sha512-bkgD4qHlN7WxLdX8bLXdaU54TtQtAIg/ZBAfm0aje/mo3MRDo3P0hZSgr4U7O3xfX+fQmR5AP04JS/TGcZLcFA==
+ dependencies:
+ "@oxc-project/types" "^0.127.0"
+ optionalDependencies:
+ "@oxc-parser/binding-android-arm-eabi" "0.127.0"
+ "@oxc-parser/binding-android-arm64" "0.127.0"
+ "@oxc-parser/binding-darwin-arm64" "0.127.0"
+ "@oxc-parser/binding-darwin-x64" "0.127.0"
+ "@oxc-parser/binding-freebsd-x64" "0.127.0"
+ "@oxc-parser/binding-linux-arm-gnueabihf" "0.127.0"
+ "@oxc-parser/binding-linux-arm-musleabihf" "0.127.0"
+ "@oxc-parser/binding-linux-arm64-gnu" "0.127.0"
+ "@oxc-parser/binding-linux-arm64-musl" "0.127.0"
+ "@oxc-parser/binding-linux-ppc64-gnu" "0.127.0"
+ "@oxc-parser/binding-linux-riscv64-gnu" "0.127.0"
+ "@oxc-parser/binding-linux-riscv64-musl" "0.127.0"
+ "@oxc-parser/binding-linux-s390x-gnu" "0.127.0"
+ "@oxc-parser/binding-linux-x64-gnu" "0.127.0"
+ "@oxc-parser/binding-linux-x64-musl" "0.127.0"
+ "@oxc-parser/binding-openharmony-arm64" "0.127.0"
+ "@oxc-parser/binding-wasm32-wasi" "0.127.0"
+ "@oxc-parser/binding-win32-arm64-msvc" "0.127.0"
+ "@oxc-parser/binding-win32-ia32-msvc" "0.127.0"
+ "@oxc-parser/binding-win32-x64-msvc" "0.127.0"
+
+oxc-resolver@^11.19.1:
+ version "11.24.2"
+ resolved "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.24.2.tgz#85c08d9f5797e600175fa8524d2d271c685d97cf"
+ integrity sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==
+ optionalDependencies:
+ "@oxc-resolver/binding-android-arm-eabi" "11.24.2"
+ "@oxc-resolver/binding-android-arm64" "11.24.2"
+ "@oxc-resolver/binding-darwin-arm64" "11.24.2"
+ "@oxc-resolver/binding-darwin-x64" "11.24.2"
+ "@oxc-resolver/binding-freebsd-x64" "11.24.2"
+ "@oxc-resolver/binding-linux-arm-gnueabihf" "11.24.2"
+ "@oxc-resolver/binding-linux-arm-musleabihf" "11.24.2"
+ "@oxc-resolver/binding-linux-arm64-gnu" "11.24.2"
+ "@oxc-resolver/binding-linux-arm64-musl" "11.24.2"
+ "@oxc-resolver/binding-linux-ppc64-gnu" "11.24.2"
+ "@oxc-resolver/binding-linux-riscv64-gnu" "11.24.2"
+ "@oxc-resolver/binding-linux-riscv64-musl" "11.24.2"
+ "@oxc-resolver/binding-linux-s390x-gnu" "11.24.2"
+ "@oxc-resolver/binding-linux-x64-gnu" "11.24.2"
+ "@oxc-resolver/binding-linux-x64-musl" "11.24.2"
+ "@oxc-resolver/binding-openharmony-arm64" "11.24.2"
+ "@oxc-resolver/binding-wasm32-wasi" "11.24.2"
+ "@oxc-resolver/binding-win32-arm64-msvc" "11.24.2"
+ "@oxc-resolver/binding-win32-x64-msvc" "11.24.2"
+
p-limit@^3.0.2:
version "3.1.0"
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b"
@@ -8173,14 +8316,13 @@ react-dom@19.2.8, "react-dom@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0":
dependencies:
scheduler "^0.27.0"
-react-dropzone@15.0.0:
- version "15.0.0"
- resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-15.0.0.tgz#bd03c7c2b14fe4ea9db1a9c74502b85339f2e505"
- integrity sha512-lGjYV/EoqEjEWPnmiSvH4v5IoIAwQM2W4Z1C0Q/Pw2xD0eVzKPS359BQTUMum+1fa0kH2nrKjuavmTPOGhpLPg==
+react-dropzone@20.0.0:
+ version "20.0.0"
+ resolved "https://registry.npmjs.org/react-dropzone/-/react-dropzone-20.0.0.tgz#75eade48bede945796aac3a25cba5488d5d640a9"
+ integrity sha512-Xw8tvvVPJQzj8ir5wivUMzA+G6R+aGhdU5KQzUMvVBlJNb26AW/0137VoYVmb5UgZcbhM9OCpjE4KOqqSL9QuQ==
dependencies:
- attr-accept "^2.2.4"
- file-selector "^2.1.0"
- prop-types "^15.8.1"
+ attr-accept "^2.2.5"
+ file-selector "^4.1.0"
react-error-boundary@^6.1.2:
version "6.1.2"
@@ -8788,16 +8930,11 @@ semver@^6.3.1:
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
-semver@^7.5.3:
+semver@^7.5.3, semver@^7.7.1, semver@^7.7.3:
version "7.8.5"
resolved "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69"
integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==
-semver@^7.7.1, semver@^7.7.3:
- version "7.7.4"
- resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a"
- integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==
-
set-function-length@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449"
@@ -9027,24 +9164,28 @@ stop-iteration-iterator@^1.1.0:
es-errors "^1.3.0"
internal-slot "^1.1.0"
-storybook@10.3.5:
- version "10.3.5"
- resolved "https://registry.npmjs.org/storybook/-/storybook-10.3.5.tgz#77bc13217db7b3c2ba5a73c1f2d469bfc0675da1"
- integrity sha512-uBSZu/GZa9aEIW3QMGvdQPMZWhGxSe4dyRWU8B3/Vd47Gy/XLC7tsBxRr13txmmPOEDHZR94uLuq0H50fvuqBw==
+storybook@10.5.7:
+ version "10.5.7"
+ resolved "https://registry.npmjs.org/storybook/-/storybook-10.5.7.tgz#adfc465e51f337291c095278c23f1b8024ef2da7"
+ integrity sha512-oiKvWIwIoOhFP1i6dASYyMXwPHKEtVZMshqSB7EvIVYjWRh0l9H7gHEt1z4Gh2rLGFMekWdsm4s94rvwpR7gkg==
dependencies:
"@storybook/global" "^5.0.0"
- "@storybook/icons" "^2.0.1"
- "@testing-library/jest-dom" "^6.9.1"
+ "@storybook/icons" "^2.0.2"
+ "@testing-library/dom" "^10.4.1"
+ "@testing-library/jest-dom" "6.9.1"
"@testing-library/user-event" "^14.6.1"
"@vitest/expect" "3.2.4"
"@vitest/spy" "3.2.4"
"@webcontainer/env" "^1.1.1"
- esbuild "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0"
+ esbuild "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0"
+ jsonc-parser "^3.3.1"
open "^10.2.0"
+ oxc-parser "^0.127.0"
+ oxc-resolver "^11.19.1"
recast "^0.23.5"
semver "^7.7.3"
use-sync-external-store "^1.5.0"
- ws "^8.18.0"
+ ws "^8.21.1"
strict-event-emitter@^0.5.1:
version "0.5.1"
@@ -9434,7 +9575,7 @@ tsconfig-paths@^4.2.0:
minimist "^1.2.6"
strip-bom "^3.0.0"
-tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.4.0, tslib@^2.7.0, tslib@^2.8.0:
+tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.4.0, tslib@^2.8.0:
version "2.8.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
@@ -9946,10 +10087,10 @@ wrap-ansi@^7.0.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
-ws@^8.18.0, ws@^8.19.0:
- version "8.21.1"
- resolved "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz#045650cd4b1207809e7547146223c3814a9af586"
- integrity sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==
+ws@^8.19.0, ws@^8.21.1:
+ version "8.21.3"
+ resolved "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz#660b4faddb6a3e575c86e078126919961f4de4fc"
+ integrity sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==
wsl-utils@^0.1.0:
version "0.1.0"