+
+
+ )
+}
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 */}
+
+
+
+
+ >
+ );
+};
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 (
+ <>
+
+
+
+
+ >
+ )
+}
+
+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 (
+
+ )
+}
+
+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 (
+ <>
+
+
+ {
+ 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)
+ }}
+ >
+
+
+
+ >
+ )
+}
+
+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 (
+
+ )
+}
+
+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 (
-
- );
-};
+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 (
+
+ );
+};
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" ? (
-
+
{
}}
/>
-
+
{
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/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 e5babd008db3..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,
@@ -476,9 +477,14 @@ export const PermissionsReportButton = ({ permissionsData, tenantName }) => {
{dialogOpen && (
-
+
{documentNode}
-
+
)}
diff --git a/src/components/CippPdf/SharingReportButton.jsx b/src/components/CippPdf/SharingReportButton.jsx
index 136a515f0c93..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,
@@ -467,9 +468,14 @@ export const SharingReportButton = ({ sharingData, tenantName }) => {
{dialogOpen && (
-
+
{documentNode}
-
+
)}
diff --git a/src/components/CippPdf/previewSampleData.js b/src/components/CippPdf/previewSampleData.js
index f3a607f5088d..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: [
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/CippBrandingSettings.jsx b/src/components/CippSettings/CippBrandingSettings.jsx
index 6a01076a5a58..44ca040a3b06 100644
--- a/src/components/CippSettings/CippBrandingSettings.jsx
+++ b/src/components/CippSettings/CippBrandingSettings.jsx
@@ -1436,7 +1436,7 @@ const CippBrandingSettings = () => {
},
}}
/>
-
+
{
+
+
+ {/* 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,18 +146,15 @@ 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
@@ -217,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,
})) || []
)
@@ -258,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)
@@ -304,6 +281,7 @@ export const CIPPTableToptoolbar = React.memo(
const restorationKey = `${pageName}-graph`
if (
+ pageName &&
settings.persistFilters &&
settings.lastUsedFilters &&
settings.lastUsedFilters[pageName] &&
@@ -373,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 = () => {
@@ -418,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(() => {
@@ -439,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)
@@ -454,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,
@@ -473,7 +458,7 @@ export const CIPPTableToptoolbar = React.memo(
pageName,
table,
usedColumns,
- getRequestData?.isFetching,
+ viewMode,
])
const presetList = ApiGetCall({
@@ -532,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)
}
@@ -562,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)
}
@@ -660,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(
@@ -674,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,
@@ -696,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,
@@ -801,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()
}
@@ -818,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(/^\//, '')
@@ -882,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 */}
@@ -1071,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 }}
@@ -1081,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 */}
{/* 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..f817177544bd
--- /dev/null
+++ b/src/components/CippTable/CippMobileCardList.jsx
@@ -0,0 +1,442 @@
+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 ac35062aa3c3..66521172833b 100644
--- a/src/components/CippTable/CippQueueTracker.js
+++ b/src/components/CippTable/CippQueueTracker.js
@@ -258,7 +258,7 @@ export const CippQueueTracker = ({ queueId, queryKey, title, onQueueComplete })
/>
-
+
Total Tasks: {(persistentQueueData || queueData).TotalTasks || 0}
@@ -364,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/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/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
-
-
-
{
+ {/* 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 0ad27744fbea..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'
@@ -134,22 +144,41 @@ 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.baseTag)
+ 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.baseTag)
- }, [releaseMeta.baseTag])
+ // New build -> re-pick from the catalog rather than pinning to this build's tag
+ setSelectedReleaseTag(null)
+ }, [releaseMeta.releaseTag])
useEffect(() => {
if (typeof window === 'undefined') {
@@ -191,17 +220,14 @@ 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.find((release) => release.releaseTag === releaseMeta.baseTag) ||
- releaseCatalog[0]
+ const fallbackRelease = pickDisplayRelease(releaseCatalog, releaseMeta)
if (fallbackRelease) {
setSelectedReleaseTag(fallbackRelease.releaseTag)
}
@@ -211,7 +237,13 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => {
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,
@@ -307,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)
}
@@ -367,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'}
-
-
+
+
@@ -423,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}
- {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 }}
/>
- 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 ac06341b8b8c..d4a68dc0e98c 100644
--- a/src/data/standards.json
+++ b/src/data/standards.json
@@ -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",
@@ -6021,6 +6087,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 +6121,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,
@@ -6463,6 +6549,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",
@@ -6939,7 +7087,8 @@
"label": "Select Sensitivity Label Templates",
"api": {
"url": "/api/ListSensitivityLabelTemplates",
- "labelField": "name",
+ "labelField": "DisplayName",
+ "altLabelField": "Name",
"valueField": "GUID",
"queryKey": "ListSensitivityLabelTemplates"
}
@@ -7655,6 +7804,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. 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 only prerequisite for Microsoft Purview Message Encryption. 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",
+ "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..9ab87cf40918 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,12 @@ 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";
export const HeaderedTabbedLayout = (props) => {
const {
@@ -27,16 +30,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 +57,221 @@ export const HeaderedTabbedLayout = (props) => {
{ shallow: true }
);
},
- [router]
+ [router, queryParams]
);
+ const handleTabsChange = useCallback((event, value) => navigateToTab(value), [navigateToTab]);
+
const currentTab = tabOptions.find((option) => option.path === pathname);
- return (
-
-
-
-
+ // 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: tabOptions,
+ 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 && (
+
+
+ {tabOptions.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..cc89fd11a794 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
@@ -37,57 +40,85 @@ export const TabbedLayout = (props) => {
return tabs.filter((option) => !disabledPages.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..8dab949ae483 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',
@@ -215,6 +220,7 @@ export const nativeMenuItems = [
title: 'Standards & Drift',
permissions: [
'Tenant.Standards.*',
+ 'Tenant.Baselines.*',
'Tenant.BestPracticeAnalyser.*',
'Tenant.DomainAnalyser.*',
],
@@ -225,12 +231,12 @@ export const nativeMenuItems = [
permissions: ['Tenant.Standards.*'],
scope: 'global',
},
- // Baselines mockup - hidden from the nav for now; reach it directly
+ // Baselines - hidden from the nav for now; reach it directly
// at /tenant/baselines
// {
// title: 'Baselines (Preview)',
// path: '/tenant/baselines',
- // permissions: ['Tenant.Standards.*'],
+ // permissions: ['Tenant.Baselines.*'],
// scope: 'global',
// },
{
@@ -1086,6 +1092,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..44c33201f056 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([])
@@ -203,7 +206,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 +308,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 +343,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) => (
-
-
+
+
-
+
-
+
{
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}
/>
}>
- 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/CippAddUserDrawer.test.jsx b/tests/components/CippComponents/CippAddUserDrawer.test.jsx
new file mode 100644
index 000000000000..f77592a792cd
--- /dev/null
+++ b/tests/components/CippComponents/CippAddUserDrawer.test.jsx
@@ -0,0 +1,197 @@
+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() })
+
+// 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 okGet([])
+ if (url.startsWith('/api/ListExtensionsConfig')) return okGet({})
+ if (url.startsWith('/api/ListGroups')) return okGet([])
+ if (url.startsWith('/api/ListCustomDataMappings')) return okGet({ Results: [] })
+ if (url.startsWith('/api/ListUserGroups')) return okGet([])
+ return idleGet
+ })
+ ApiGetCallWithPagination.mockImplementation(({ url }) => {
+ if (url === '/api/ListGraphRequest') {
+ return {
+ 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(),
+ }
+ }
+ return { ...idleGet, fetchNextPage: vi.fn() }
+ })
+ 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' },
+ })
+ })
+})
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 e5a47bc5845a..90dbd2aa6a36 100644
--- a/tests/components/CippComponents/CippAutocomplete.test.jsx
+++ b/tests/components/CippComponents/CippAutocomplete.test.jsx
@@ -345,4 +345,51 @@ describe('CippAutoComplete', () => {
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
+
+
+ >
+ )
+ },
+ 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
index 06c181b55374..79794a7045a0 100644
--- a/tests/components/CippComponents/CippBreadcrumbNav.test.jsx
+++ b/tests/components/CippComponents/CippBreadcrumbNav.test.jsx
@@ -5,10 +5,16 @@ import { CippBreadcrumbNav } from '../../../src/components/CippComponents/CippBr
// 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: '/tenant/administration/tenants/groups',
- asPath: '/tenant/administration/tenants/groups',
+ pathname: routerState.pathname,
+ asPath: routerState.pathname,
query: {},
isReady: true,
push: () => Promise.resolve(),
@@ -18,6 +24,49 @@ vi.mock('next/router', () => ({
}))
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()
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
+
+
+ );
+
+ 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/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..bfa93e89bb11 100644
--- a/tests/components/CippTable/CIPPTableToptoolbar.test.jsx
+++ b/tests/components/CippTable/CIPPTableToptoolbar.test.jsx
@@ -279,6 +279,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({}, {
+ settings: settingsWith({
+ persistFilters: true,
+ setLastUsedFilter: vi.fn(),
+ lastUsedFilters: {
+ '': { 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 +388,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.test.jsx b/tests/components/CippTable/CippDataTable.test.jsx
index bdc5023185bf..2b7c1dd21658 100644
--- a/tests/components/CippTable/CippDataTable.test.jsx
+++ b/tests/components/CippTable/CippDataTable.test.jsx
@@ -1,8 +1,10 @@
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'
const basicData = [
{ displayName: 'Alice Smith', mail: 'alice@contoso.com', department: 'IT', accountEnabled: true },
@@ -310,3 +312,727 @@ 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' }]
+ const relatedRows = [{ id: 'child-1', displayName: 'Jane Doe' }]
+
+ 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'))
+
+ 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'))
+
+ 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('renders cached report columns in table view without a stale column order crash', async () => {
+ renderWithProviders(
+
+ )
+
+ await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument())
+ expect(screen.getByRole('columnheader', { name: 'Members' })).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'View members' })).not.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/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..a8f8f4dc51a8 100644
--- a/tests/components/PrivateRoute.test.jsx
+++ b/tests/components/PrivateRoute.test.jsx
@@ -84,6 +84,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
index e74a1c767f51..2468b0f51af8 100644
--- a/tests/components/ReleaseNotesDialog.test.jsx
+++ b/tests/components/ReleaseNotesDialog.test.jsx
@@ -1,5 +1,5 @@
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 { renderWithProviders } from '../test-utils'
@@ -8,6 +8,13 @@ import { renderWithProviders } from '../test-utils'
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'
@@ -48,6 +55,7 @@ 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()
@@ -55,18 +63,36 @@ beforeEach(() => {
})
describe('ReleaseNotesDialog', () => {
- it('opens on the .0 base release even when running a hotfix build', async () => {
+ // 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.findByText('Release notes for v10.8.0 - Ramos Melon Fizz')).toBeInTheDocument()
- expect(screen.getByText('Notes for the base release of the 10.8 series')).toBeInTheDocument()
+ 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.findByText('Release notes for v10.8.0 - Ramos Melon Fizz')
+ 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())
@@ -87,7 +113,45 @@ describe('ReleaseNotesDialog', () => {
renderWithProviders()
- expect(await screen.findByText('Release notes for v10.9.0 - Something Newer')).toBeInTheDocument()
+ 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 () => {
@@ -95,7 +159,7 @@ describe('ReleaseNotesDialog', () => {
renderWithProviders()
- expect(await screen.findByText('Release notes for v10.9.0 - Something Newer')).toBeInTheDocument()
+ expect(await screen.findByDisplayValue('v10.9.0 - Something Newer')).toBeInTheDocument()
})
it('honours a permanent dismissal', async () => {
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/pages/MessageEncryptionPage.test.jsx b/tests/pages/MessageEncryptionPage.test.jsx
new file mode 100644
index 000000000000..c378ae2bb994
--- /dev/null
+++ b/tests/pages/MessageEncryptionPage.test.jsx
@@ -0,0 +1,136 @@
+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()
+ api.paginated = paginatedResult([
+ { 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('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/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-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/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"