setDrawerVisible(false)}
+ />
+ );
+ },
+ },
{
label: "Create template based on policy",
type: "POST",
diff --git a/src/theme/base/create-components.jsx b/src/theme/base/create-components.jsx
index 11a42940133c..b1ed5db2d4f1 100644
--- a/src/theme/base/create-components.jsx
+++ b/src/theme/base/create-components.jsx
@@ -227,10 +227,29 @@ export const createComponents = () => {
margin: 0,
width: "100%",
maxWidth: "100%",
- maxHeight: "100%",
borderRadius: 0,
+ // A tall dialog fills the screen and, in a home-screen install, runs under the
+ // iOS status bar. Keep it below the inset; fullscreen dialogs pad themselves.
+ "&:not(.MuiDialog-paperFullScreen)": {
+ marginTop: "env(safe-area-inset-top, 0px)",
+ maxHeight: "calc(100% - env(safe-area-inset-top, 0px))",
+ },
},
},
+ // Fullscreen dialogs and edge drawers reach the top of the screen. In a home-screen
+ // (standalone) install with viewport-fit=cover that is under the iOS status bar, so
+ // pad by the safe-area inset the same way the top nav does. 0px everywhere else.
+ paperFullScreen: {
+ paddingTop: "env(safe-area-inset-top, 0px)",
+ },
+ },
+ },
+ MuiDrawer: {
+ styleOverrides: {
+ paper: ({ ownerState }) =>
+ ownerState.variant === "temporary" && ["left", "right"].includes(ownerState.anchor)
+ ? { paddingTop: "env(safe-area-inset-top, 0px)" }
+ : {},
},
},
MuiDialogActions: {
diff --git a/src/utils/format-ca-coverage-reason.js b/src/utils/format-ca-coverage-reason.js
new file mode 100644
index 000000000000..56386018362c
--- /dev/null
+++ b/src/utils/format-ca-coverage-reason.js
@@ -0,0 +1,339 @@
+const GUEST_TYPE_LABELS = {
+ none: 'None',
+ internalGuest: 'Internal guest',
+ b2bCollaborationGuest: 'B2B collaboration guest',
+ b2bCollaborationMember: 'B2B collaboration member',
+ b2bDirectConnectUser: 'B2B direct connect user',
+ otherExternalUser: 'Other external user',
+ serviceProvider: 'Service provider',
+}
+
+const tokenFromValue = (value) => {
+ if (typeof value !== 'string' || !value.includes(':')) return null
+ return value.slice(value.indexOf(':') + 1)
+}
+
+export const formatGuestTypes = (raw) => {
+ if (!raw || typeof raw !== 'string') return null
+ return raw
+ .split(',')
+ .map((part) => GUEST_TYPE_LABELS[part.trim()] || part.trim())
+ .filter(Boolean)
+ .join(', ')
+}
+
+/**
+ * Turn a CA coverage reason object into a short human-readable sentence.
+ */
+const formatViaNestedNames = (viaNestedGroups) =>
+ (Array.isArray(viaNestedGroups) ? viaNestedGroups : [])
+ .map((group) => group?.name || group?.displayName)
+ .filter(Boolean)
+ .join(', ')
+
+const parseReasonNames = (reason) => {
+ const { type, label, value, viaNestedGroups } = reason || {}
+ const token = tokenFromValue(value)
+ const groupName = label?.match(/^Group:\s*(.+?)(\s*\(nested\))?$/i)?.[1]?.trim()
+ const roleViaGroup = label?.match(/^Role:\s*(.+?)\s+via group\s+(.+)$/i)
+ const roleName = roleViaGroup
+ ? roleViaGroup[1].trim()
+ : label?.match(/^Role:\s*(.+)$/i)?.[1]?.trim()
+ const roleGroupName = roleViaGroup?.[2]?.trim() || reason?.viaGroupLabel
+ const guestTypesRaw =
+ label?.match(/^Guest types:\s*(.+)$/i)?.[1]?.trim() || (type?.includes('Guests') ? token : null)
+ const viaNested = (Array.isArray(viaNestedGroups) ? viaNestedGroups : [])
+ .map((group) => ({
+ id: group?.id,
+ name: group?.name || group?.displayName || group?.id,
+ }))
+ .filter((group) => group.name)
+
+ return {
+ token,
+ groupName,
+ roleName,
+ roleGroupName,
+ guestTypesRaw,
+ viaNested,
+ viaNestedNames: formatViaNestedNames(viaNestedGroups),
+ }
+}
+
+const textPart = (value) => ({ type: 'text', value })
+const entityPart = (kind, name, id) => ({ type: kind, name, id: id || null })
+
+/**
+ * Sentence parts for rich why-drawer rendering (text + linkable group/role chips).
+ * formatCaCoverageReason joins these for plain-text contexts (grid/export).
+ */
+export const getCaCoverageReasonParts = (reason) => {
+ if (!reason || typeof reason !== 'object') return []
+
+ const { type, label, transitive, id, viaGroupId, value } = reason
+ const { token, groupName, roleName, roleGroupName, guestTypesRaw, viaNested } =
+ parseReasonNames(reason)
+
+ switch (type) {
+ case 'includeUsers':
+ if (token === 'All') return [textPart('Policy includes all users')]
+ if (token === 'GuestsOrExternalUsers') {
+ return [textPart('Matched as guest or external user')]
+ }
+ return [textPart('Listed directly under Include users')]
+
+ case 'excludeUsers':
+ if (token === 'GuestsOrExternalUsers') {
+ return [textPart('Excluded as guest or external user')]
+ }
+ return [textPart('Listed directly under Exclude users')]
+
+ case 'includeGroups': {
+ if (!groupName) return [textPart(label || 'Member of an included group')]
+ const parts = [textPart('Member of group '), entityPart('group', groupName, id)]
+ if (viaNested.length) {
+ parts.push(textPart(viaNested.length === 1 ? ' via nested group ' : ' via nested groups '))
+ viaNested.forEach((group, index) => {
+ if (index > 0) parts.push(textPart(', '))
+ parts.push(entityPart('group', group.name, group.id))
+ })
+ } else if (transitive) {
+ parts.push(textPart(' via a nested group'))
+ }
+ return parts
+ }
+
+ case 'excludeGroups': {
+ if (!groupName) return [textPart(label || 'Excluded as member of a group')]
+ const parts = [
+ textPart('Excluded as member of group '),
+ entityPart('group', groupName, id),
+ ]
+ if (viaNested.length) {
+ parts.push(textPart(viaNested.length === 1 ? ' via nested group ' : ' via nested groups '))
+ viaNested.forEach((group, index) => {
+ if (index > 0) parts.push(textPart(', '))
+ parts.push(entityPart('group', group.name, group.id))
+ })
+ } else if (transitive) {
+ parts.push(textPart(' via a nested group'))
+ }
+ return parts
+ }
+
+ case 'includeRoles': {
+ if (!roleName) return [textPart(label || 'Has an included directory role')]
+ const parts = [
+ textPart('Has directory role '),
+ entityPart('role', roleName, id),
+ ]
+ if (roleGroupName) {
+ parts.push(textPart(' via group '))
+ parts.push(entityPart('group', roleGroupName, viaGroupId))
+ if (viaNested.length) {
+ parts.push(textPart(viaNested.length === 1 ? ' via nested group ' : ' via nested groups '))
+ viaNested.forEach((group, index) => {
+ if (index > 0) parts.push(textPart(', '))
+ parts.push(entityPart('group', group.name, group.id))
+ })
+ }
+ }
+ return parts
+ }
+
+ case 'excludeRoles': {
+ if (!roleName) return [textPart(label || 'Excluded because of a directory role')]
+ const parts = [
+ textPart('Excluded because of directory role '),
+ entityPart('role', roleName, id),
+ ]
+ if (roleGroupName) {
+ parts.push(textPart(' via group '))
+ parts.push(entityPart('group', roleGroupName, viaGroupId))
+ if (viaNested.length) {
+ parts.push(textPart(viaNested.length === 1 ? ' via nested group ' : ' via nested groups '))
+ viaNested.forEach((group, index) => {
+ if (index > 0) parts.push(textPart(', '))
+ parts.push(entityPart('group', group.name, group.id))
+ })
+ }
+ }
+ return parts
+ }
+
+ case 'includeGuestsOrExternalUsers': {
+ const types = formatGuestTypes(guestTypesRaw)
+ return [
+ textPart(
+ types
+ ? `Included as guest or external user (${types})`
+ : 'Included as guest or external user'
+ ),
+ ]
+ }
+
+ case 'excludeGuestsOrExternalUsers': {
+ const types = formatGuestTypes(guestTypesRaw)
+ return [
+ textPart(
+ types
+ ? `Excluded as guest or external user (${types})`
+ : 'Excluded as guest or external user'
+ ),
+ ]
+ }
+
+ default:
+ return [textPart(label || value || type || '')]
+ }
+}
+
+export const formatCaCoverageReason = (reason) =>
+ getCaCoverageReasonParts(reason)
+ .map((part) => (part.type === 'text' ? part.value : part.name))
+ .join('')
+
+export const formatCaCoverageReasons = (reasons) =>
+ (Array.isArray(reasons) ? reasons : []).map(formatCaCoverageReason).filter(Boolean)
+
+export const formatCaCoverageStatus = (status) => {
+ if (status === 'covered') return 'Covered by this policy'
+ if (status === 'excluded') return 'Excluded from this policy'
+ return status || ''
+}
+
+const REASON_TYPE_META = {
+ includeUsers: {
+ chip: 'Include users',
+ tooltip:
+ 'This identity is listed under Include users on the policy, or matched a special token such as All or Guests or external users.',
+ },
+ excludeUsers: {
+ chip: 'Exclude users',
+ tooltip:
+ 'This identity is listed under Exclude users on the policy, or matched a guest or external users token there.',
+ },
+ includeGroups: {
+ chip: 'Include groups',
+ tooltip:
+ 'This identity is a member of a group under Include groups. Nested group membership counts.',
+ },
+ excludeGroups: {
+ chip: 'Exclude groups',
+ tooltip:
+ 'This identity is a member of a group under Exclude groups. Nested group membership counts.',
+ },
+ includeRoles: {
+ chip: 'Include roles',
+ tooltip:
+ 'This identity has a directory role under Include roles, either assigned directly or through a group that holds the role.',
+ },
+ excludeRoles: {
+ chip: 'Exclude roles',
+ tooltip:
+ 'This identity has a directory role under Exclude roles, either assigned directly or through a group that holds the role.',
+ },
+ includeGuestsOrExternalUsers: {
+ chip: 'Include guests',
+ tooltip:
+ 'This identity matched the Include guests or external users block on the policy.',
+ },
+ excludeGuestsOrExternalUsers: {
+ chip: 'Exclude guests',
+ tooltip:
+ 'This identity matched the Exclude guests or external users block on the policy.',
+ },
+}
+
+/**
+ * Chip label + tooltip for a coverage reason type (used in the why drawer).
+ */
+export const getCaCoverageReasonTypeMeta = (reason) => {
+ const type = reason?.type
+ if (type && REASON_TYPE_META[type]) return REASON_TYPE_META[type]
+ return {
+ chip: type || 'Assignment',
+ tooltip: 'How this identity was matched on the policy assignment.',
+ }
+}
+
+const GUID_PATTERN = /^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/i
+
+/**
+ * Concrete targets on a reason (group / role / via-group) for chips and deep links.
+ */
+export const getCaCoverageReasonTargets = (reason) => {
+ if (!reason || typeof reason !== 'object') return []
+
+ const { type, label, id, transitive, viaGroupId, viaGroupLabel, value } = reason
+ const targets = []
+ const token = tokenFromValue(value)
+
+ const groupName = label?.match(/^Group:\s*(.+?)(\s*\(nested\))?$/i)?.[1]?.trim()
+ const roleViaGroup = label?.match(/^Role:\s*(.+?)\s+via group\s+(.+)$/i)
+ const roleName = roleViaGroup
+ ? roleViaGroup[1].trim()
+ : label?.match(/^Role:\s*(.+)$/i)?.[1]?.trim()
+ const roleGroupName = roleViaGroup?.[2]?.trim() || viaGroupLabel
+
+ if (type === 'includeGroups' || type === 'excludeGroups') {
+ if (id || groupName) {
+ targets.push({
+ kind: 'group',
+ id: GUID_PATTERN.test(id) ? id : null,
+ name: groupName || id,
+ nested: Boolean(transitive),
+ })
+ }
+ for (const via of Array.isArray(reason.viaNestedGroups) ? reason.viaNestedGroups : []) {
+ if (!via?.id && !via?.name && !via?.displayName) continue
+ targets.push({
+ kind: 'group',
+ id: GUID_PATTERN.test(via.id) ? via.id : null,
+ name: via.name || via.displayName || via.id,
+ nested: true,
+ viaNested: true,
+ })
+ }
+ return targets
+ }
+
+ if (type === 'includeRoles' || type === 'excludeRoles') {
+ if (id || roleName) {
+ targets.push({
+ kind: 'role',
+ id: GUID_PATTERN.test(id) ? id : null,
+ name: roleName || id,
+ nested: false,
+ })
+ }
+ if (viaGroupId || roleGroupName) {
+ targets.push({
+ kind: 'group',
+ id: GUID_PATTERN.test(viaGroupId) ? viaGroupId : null,
+ name: roleGroupName || viaGroupId,
+ nested: false,
+ viaRole: true,
+ })
+ }
+ return targets
+ }
+
+ if (
+ (type === 'includeUsers' || type === 'excludeUsers') &&
+ token &&
+ !GUID_PATTERN.test(token) &&
+ token !== 'None'
+ ) {
+ targets.push({
+ kind: 'token',
+ id: null,
+ name: token === 'All' ? 'All users' : token === 'GuestsOrExternalUsers' ? 'Guests or external users' : token,
+ nested: false,
+ })
+ }
+
+ return targets
+}
+
+
diff --git a/src/utils/get-cipp-formatting.jsx b/src/utils/get-cipp-formatting.jsx
index 88b4ad5bc131..0226d553eb2f 100644
--- a/src/utils/get-cipp-formatting.jsx
+++ b/src/utils/get-cipp-formatting.jsx
@@ -1,4 +1,4 @@
-import { Chip, Link, SvgIcon, Tooltip } from '@mui/material'
+import { Chip, Link, SvgIcon, Tooltip, Typography } from '@mui/material'
import { CippIcons } from './icon-registry'
import NextLink from 'next/link'
import { alpha } from '@mui/material/styles'
@@ -28,6 +28,10 @@ import { CippTimeAgo } from '../components/CippComponents/CippTimeAgo'
import { getCippRoleTranslation } from './get-cipp-role-translation'
import { getCippTranslation } from './get-cipp-translation'
import DOMPurify from 'dompurify'
+import {
+ formatCaCoverageReason,
+ formatCaCoverageReasons,
+} from './format-ca-coverage-reason'
import { getSignInErrorCodeTranslation } from './get-cipp-signin-errorcode-translation'
import { CollapsibleChipList } from '../components/CippComponents/CollapsibleChipList'
import countryList from '../data/countryList.json'
@@ -186,13 +190,33 @@ export const getCippFormatting = (
}
if (cellNameLower === 'compliancestate') {
- if (isText) return data
- const label = data?.label ?? data
+ const raw = data?.label ?? data
+ const complianceStateLabels = {
+ compliant: 'Compliant',
+ remediated: 'Remediated',
+ noncompliant: 'Non-compliant',
+ error: 'Error',
+ conflict: 'Conflict',
+ notapplicable: 'Not applicable',
+ unknown: 'Unknown',
+ notassigned: 'Not assigned',
+ ingraceperiod: 'In grace period',
+ }
const complianceStateColor = {
compliant: 'success',
+ remediated: 'success',
noncompliant: 'error',
+ error: 'error',
+ conflict: 'warning',
+ ingraceperiod: 'warning',
+ notapplicable: 'default',
+ unknown: 'default',
+ notassigned: 'default',
}
- const color = complianceStateColor[String(label).toLowerCase()] ?? 'default'
+ const key = String(raw ?? '').toLowerCase()
+ const label = complianceStateLabels[key] ?? raw
+ if (isText) return label
+ const color = complianceStateColor[key] ?? 'default'
return
}
@@ -687,10 +711,18 @@ export const getCippFormatting = (
'denied - delete pending': 'warning',
'skipped - no license': 'default',
'no data': 'default',
+ covered: 'success',
+ excluded: 'warning',
}
const baselineColor = baselineStatusColors[String(data).toLowerCase()]
if (baselineColor) {
- if (isText) return data
+ const displayLabel =
+ String(data).toLowerCase() === 'covered'
+ ? 'Covered'
+ : String(data).toLowerCase() === 'excluded'
+ ? 'Excluded'
+ : data
+ if (isText) return displayLabel
// Pending states answer the "when does something happen?" question inline.
const baselineStatusTooltips = {
'no data': 'Not collected yet - happens automatically on the next run.',
@@ -705,7 +737,7 @@ export const getCippFormatting = (
const chip = (
@@ -1502,6 +1534,38 @@ export const getCippFormatting = (
return isText ? data :
}
+ // CA policy identity coverage: grid shows a count/top-reason summary; full lists belong in off-canvas.
+ if (cellName === 'includeReasons' || cellName === 'excludeReasons') {
+ const reasons = Array.isArray(data) ? data : []
+ const noun = cellName === 'includeReasons' ? 'include' : 'exclude'
+ const labels = formatCaCoverageReasons(reasons)
+ if (isText) {
+ return labels.length > 0 ? labels.join(' · ') : ''
+ }
+ if (reasons.length === 0) {
+ return (
+
+ -
+
+ )
+ }
+ if (reasons.length === 1) {
+ return (
+
+ {labels[0]}
+
+ )
+ }
+ const summary = `${reasons.length} ${noun}s`
+ return (
+
+
+ {summary}
+
+
+ )
+ }
+
// handle autocomplete labels
if (data?.label && data?.value) {
return isText ? (
diff --git a/src/utils/instance-diagnostics.js b/src/utils/instance-diagnostics.js
new file mode 100644
index 000000000000..8533507c9df9
--- /dev/null
+++ b/src/utils/instance-diagnostics.js
@@ -0,0 +1,100 @@
+// Pure helpers for the Diagnostics tab — kept dependency-free so they're cheap to unit test.
+
+// Backend check ids are machine-readable slugs; map the known ones to readable labels.
+// Unknown ids (future checks) pass through unchanged.
+export const CHECK_LABELS = {
+ oom: "Out of memory",
+ "heap-headroom": "Heap headroom",
+ watchdog: "Watchdog restarts",
+ "pool-exhausted": "HTTP worker pool",
+ "stalled-runs": "Stalled runs",
+ "api-clients": "API clients",
+ restarts: "Container restarts",
+ orchestrator: "Orchestrator",
+ "container-log": "Platform container log",
+};
+export const getCheckLabel = (id) => CHECK_LABELS[id] ?? id;
+
+// Sums Clients[].Count across every Timeline bucket, grouped by AppId.
+export const aggregateDiagnosticsClients = (buckets) => {
+ const totals = new Map();
+ for (const bucket of buckets ?? []) {
+ for (const client of bucket?.Clients ?? []) {
+ if (!client?.AppId) continue;
+ const existing = totals.get(client.AppId) ?? {
+ AppId: client.AppId,
+ AppName: client.AppName,
+ IP: client.IP,
+ Count: 0,
+ };
+ existing.Count += Number(client.Count) || 0;
+ if (client.AppName) existing.AppName = client.AppName;
+ if (client.IP) existing.IP = client.IP;
+ totals.set(client.AppId, existing);
+ }
+ }
+ const rows = Array.from(totals.values()).sort((a, b) => b.Count - a.Count);
+ const grandTotal = rows.reduce((sum, r) => sum + r.Count, 0);
+ return rows.map((r) => ({
+ ...r,
+ SharePct: grandTotal > 0 ? Math.round((r.Count / grandTotal) * 1000) / 10 : 0,
+ }));
+};
+
+// FAIL, WARN, INFO, PASS — worst first.
+const STATUS_ORDER = { FAIL: 0, WARN: 1, INFO: 2, PASS: 3 };
+export const sortDiagnosticsChecks = (checks) =>
+ [...(checks ?? [])].sort(
+ (a, b) => (STATUS_ORDER[a.Status] ?? 99) - (STATUS_ORDER[b.Status] ?? 99)
+ );
+
+// Stacked-bar series for the Health Timeline's "API Requests" chart: the top N clients by
+// total Count over the window get their own series, everything else is folded into "Other".
+export const buildRequestSeries = (buckets, topN = 4) => {
+ const totals = new Map();
+ for (const bucket of buckets ?? []) {
+ for (const client of bucket?.Clients ?? []) {
+ if (!client?.AppId) continue;
+ const existing = totals.get(client.AppId) ?? { AppId: client.AppId, AppName: client.AppName, Count: 0 };
+ existing.Count += Number(client.Count) || 0;
+ if (client.AppName) existing.AppName = client.AppName;
+ totals.set(client.AppId, existing);
+ }
+ }
+ const topClients = Array.from(totals.values())
+ .sort((a, b) => b.Count - a.Count)
+ .slice(0, topN);
+ const topIds = new Set(topClients.map((c) => c.AppId));
+
+ const data = (buckets ?? []).map((bucket) => {
+ const row = { Bucket: bucket.Bucket };
+ for (const id of topIds) row[id] = 0;
+ let other = 0;
+ for (const client of bucket?.Clients ?? []) {
+ if (!client?.AppId) continue;
+ const count = Number(client.Count) || 0;
+ if (topIds.has(client.AppId)) {
+ row[client.AppId] += count;
+ } else {
+ other += count;
+ }
+ }
+ if (other > 0) row.Other = other;
+ return row;
+ });
+
+ return { data, series: topClients.map((c) => ({ AppId: c.AppId, AppName: c.AppName || c.AppId })) };
+};
+
+// Human-readable byte size, one decimal place, B/KB/MB/GB.
+export const formatBytes = (bytes) => {
+ const n = Number(bytes) || 0;
+ if (n >= 1024 ** 3) return `${(n / 1024 ** 3).toFixed(1)} GB`;
+ if (n >= 1024 ** 2) return `${(n / 1024 ** 2).toFixed(1)} MB`;
+ if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`;
+ return `${n.toFixed(1)} B`;
+};
+
+// Deep-link query for the Logs tab, scoped to one API client's calls in the current window.
+export const buildClientLogQuery = (appId, hoursWindow) =>
+ `search all files\n| where Message contains "AppId=${appId}"\n| where Timestamp > ago(${hoursWindow}h)\n| take 1000\n| sort by Timestamp desc`;
diff --git a/src/utils/role-detail-shared.js b/src/utils/role-detail-shared.js
new file mode 100644
index 000000000000..a1682b0a4378
--- /dev/null
+++ b/src/utils/role-detail-shared.js
@@ -0,0 +1,32 @@
+// Shared copy for the role detail header chips and field help icons.
+
+export const POLICY_BELOW_FLOOR_TOOLTIP =
+ "This role's PIM settings are weaker than CIPP's secure floor: activation must expire within 24 hours and require MFA or an authentication context plus a justification; eligible and active assignments must expire within a year; creating an active assignment requires a justification. Entra defaults often sit below this floor."
+
+export const PRIVILEGED_ROLE_TOOLTIP =
+ "This role is on CIPP's privileged-roles list (the same list standards and alerts use) — for example Global, Security, Exchange, SharePoint, User, Conditional Access, and Application administrators."
+
+export const formatPimDuration = (iso) => {
+ if (!iso) return 'No expiration (permanent allowed)'
+ try {
+ // PT8H / P90D / P365D — enough for the values PIM policies use.
+ const match = String(iso).match(/^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?)?$/i)
+ if (!match) return iso
+ const days = Number(match[1] || 0)
+ const hours = Number(match[2] || 0)
+ const minutes = Number(match[3] || 0)
+ if (days >= 365 && days % 365 === 0) return `${days / 365} year${days === 365 ? '' : 's'}`
+ if (days >= 30 && days % 30 === 0) return `${days / 30} month${days === 30 ? '' : 's'}`
+ if (days > 0) return `${days} day${days === 1 ? '' : 's'}`
+ if (hours > 0) return `${hours} hour${hours === 1 ? '' : 's'}`
+ if (minutes > 0) return `${minutes} minute${minutes === 1 ? '' : 's'}`
+ return iso
+ } catch {
+ return iso
+ }
+}
+
+export const getRoleRow = (data) =>
+ Array.isArray(data)
+ ? data[0]
+ : data?.Results?.[0] ?? data?.[0]
diff --git a/tests/components/CippComponents/CippAddUserDrawer.test.jsx b/tests/components/CippComponents/CippAddUserDrawer.test.jsx
index 346bc8161732..6a4a1f011831 100644
--- a/tests/components/CippComponents/CippAddUserDrawer.test.jsx
+++ b/tests/components/CippComponents/CippAddUserDrawer.test.jsx
@@ -252,3 +252,50 @@ describe('CippAddUserDrawer - backdrop click must not wipe typed input (issue #3
expect(screen.queryByTestId('CippOffCanvas')).not.toBeInTheDocument()
}, 30000)
})
+
+describe('CippAddUserDrawer - user creation is refused under All Tenants (ticket 48312738612)', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ postState = { isPending: false, isSuccess: false, isError: false }
+ mutateSpy = vi.fn()
+ mockApis()
+ })
+
+ it('warns and keeps Create disabled under All Tenants even with the form complete', async () => {
+ const user = userEvent.setup()
+ renderWithProviders(, {
+ settings: settingsWith({
+ currentTenant: 'AllTenants',
+ usageLocation: { value: 'US', label: 'United States' },
+ }),
+ })
+
+ await user.click(screen.getByRole('button', { name: 'Add User' }))
+ await waitFor(() => {
+ expect(getDomainInput()).toHaveValue('testdomain.com')
+ })
+ await fillRequiredFields(user, { displayName: 'Blocked User', username: 'blocked.user' })
+
+ // The single-tenant warning is shown...
+ expect(screen.getByText(/single-tenant only/i)).toBeInTheDocument()
+
+ // ...and the guard keeps submit disabled despite a complete, valid, dirty form - the exact
+ // state that enables the button under a specific tenant (the create-another-user test above),
+ // so this fails if the isAllTenants guard is dropped from the disabled condition.
+ expect(screen.getByRole('button', { name: 'Create User' })).toBeDisabled()
+ }, 30000)
+
+ it('shows no single-tenant warning once a specific tenant is selected', async () => {
+ const user = userEvent.setup()
+ renderWithProviders(, {
+ settings: settingsWith({ usageLocation: { value: 'US', label: 'United States' } }),
+ })
+
+ await user.click(screen.getByRole('button', { name: 'Add User' }))
+ await waitFor(() => {
+ expect(getDomainInput()).toHaveValue('testdomain.com')
+ })
+
+ expect(screen.queryByText(/single-tenant only/i)).not.toBeInTheDocument()
+ }, 30000)
+})
diff --git a/tests/components/CippComponents/CippBulkUserDrawer.test.jsx b/tests/components/CippComponents/CippBulkUserDrawer.test.jsx
new file mode 100644
index 000000000000..674ecce85ab1
--- /dev/null
+++ b/tests/components/CippComponents/CippBulkUserDrawer.test.jsx
@@ -0,0 +1,128 @@
+import React from 'react'
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { screen, within, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { renderWithProviders, settingsWith } from '../../test-utils'
+import { CippBulkUserDrawer } from '../../../src/components/CippComponents/CippBulkUserDrawer'
+import {
+ ApiGetCall,
+ ApiPostCall,
+ ApiGetCallWithPagination,
+} from '../../../src/api/ApiCall'
+
+vi.mock('../../../src/api/ApiCall', () => ({
+ ApiGetCall: vi.fn(),
+ ApiPostCall: vi.fn(),
+ ApiGetCallWithPagination: vi.fn(),
+}))
+
+// The license selector, the preview table and the results panel take no part in the tenant
+// guard under test, and each drags in the data-table / 2.2 MB license graph that exhausts the
+// worker, so they are stubbed. CippFormComponent stays real because the manual-add dialog needs
+// it to queue a row.
+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, and
+// CippAutoComplete pulls in CippJSONView for its option preview; neither renders here, but the
+// static imports alone are enough to kill the worker.
+vi.mock('../../../src/components/CippTable/CippDataTable', () => ({
+ CippDataTable: () => ,
+ default: () => ,
+}))
+vi.mock('../../../src/components/CippFormPages/CippJSONView', () => ({
+ default: () => null,
+}))
+// The real drawer shell renders through a MUI Drawer portal; the stub keeps the essential
+// contract - content and 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 idlePaginated = { ...idleGet, fetchNextPage: vi.fn() }
+
+let postState
+let mutateSpy
+
+function mockApis() {
+ ApiGetCall.mockImplementation(() => idleGet)
+ ApiGetCallWithPagination.mockImplementation(() => idlePaginated)
+ ApiPostCall.mockImplementation(() => ({ ...postState, mutate: mutateSpy }))
+}
+
+// Queue one user through the manual-add dialog. Any typed field is enough - handleAddItem pushes
+// whatever `addrow` holds - so with a row present the Create button's disabled state is governed
+// solely by the tenant guard rather than the empty-list default.
+async function queueOneUser(user) {
+ await user.click(screen.getByRole('button', { name: 'Add User Manually' }))
+ const dialog = await screen.findByRole('dialog')
+ await user.type(within(dialog).getAllByRole('textbox')[0], 'Test User')
+ await user.click(within(dialog).getByRole('button', { name: 'Add' }))
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+}
+
+describe('CippBulkUserDrawer - bulk creation is refused under All Tenants (ticket 48312738612)', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ postState = { isLoading: false, isSuccess: false, isError: false }
+ mutateSpy = vi.fn()
+ mockApis()
+ })
+
+ it('warns and keeps Create Users disabled with a queued row when All Tenants is selected', async () => {
+ const user = userEvent.setup()
+ renderWithProviders(, {
+ settings: settingsWith({ currentTenant: 'AllTenants' }),
+ })
+
+ await user.click(screen.getByRole('button', { name: 'Bulk Add Users' }))
+
+ // The single-tenant warning must be visible before the user wastes time building a list.
+ expect(screen.getByText(/single-tenant only/i)).toBeInTheDocument()
+
+ // Even with a row queued - the state that normally enables Create Users - the guard keeps
+ // submit disabled, because under All Tenants the write silently creates nothing.
+ await queueOneUser(user)
+ expect(screen.getByRole('button', { name: 'Create Users' })).toBeDisabled()
+ }, 30000)
+
+ it('enables Create Users and shows no warning once a specific tenant is selected', async () => {
+ const user = userEvent.setup()
+ renderWithProviders(, {
+ settings: settingsWith({ currentTenant: 'contoso.onmicrosoft.com' }),
+ })
+
+ await user.click(screen.getByRole('button', { name: 'Bulk Add Users' }))
+
+ expect(screen.queryByText(/single-tenant only/i)).not.toBeInTheDocument()
+
+ await queueOneUser(user)
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: 'Create Users' })).toBeEnabled()
+ })
+ }, 30000)
+})
diff --git a/tests/components/CippSettings/CippRoleAddEdit.test.jsx b/tests/components/CippSettings/CippRoleAddEdit.test.jsx
index 166b6bedf333..57802fb38fcc 100644
--- a/tests/components/CippSettings/CippRoleAddEdit.test.jsx
+++ b/tests/components/CippSettings/CippRoleAddEdit.test.jsx
@@ -1,4 +1,5 @@
-import React from "react";
+import React, { useState } from "react";
+import { act } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { renderWithProviders } from "../../test-utils";
@@ -52,3 +53,56 @@ describe("CippRoleAddEdit render stability", () => {
consoleError.mockRestore();
});
});
+
+// Regression: the "Set All Permissions" effect used to fire on mount with an undefined
+// selection. With the permissions list already cached that overwrote the loaded role
+// with "Cat.Obj.undefined" for every row.
+const loadedPermissions = { CIPP: { Alert: { Read: [], ReadWrite: [] } } };
+const technicianRole = {
+ RowKey: "technician",
+ Permissions: { CIPPAlert: "CIPP.Alert.Read" },
+ PermissionRules: { Include: ["CIPP.Alert.Read"], Exclude: [] },
+};
+
+describe("CippRoleAddEdit custom role advanced view", () => {
+ it("shows the saved permission levels instead of undefined", async () => {
+ pendingPermissions.data = loadedPermissions;
+ pendingPermissions.isFetching = false;
+ pendingPermissions.isSuccess = true;
+ idlePagination.data = { pages: [[technicianRole]] };
+ idlePagination.isSuccess = true;
+
+ const { findByText, queryByText } = renderWithProviders(
+
+ );
+
+ expect(await findByText("CIPP.Alert.Read")).toBeTruthy();
+ expect(queryByText(/\.undefined/)).toBeNull();
+ });
+
+ // Cold cache: the role list resolves before the permission list. Loading the role
+ // then built the grid from zero categories and the summary stayed blank.
+ it("shows the saved permission levels when the permission list arrives last", async () => {
+ pendingPermissions.data = undefined;
+ pendingPermissions.isFetching = true;
+ pendingPermissions.isSuccess = false;
+ idlePagination.data = { pages: [[technicianRole]] };
+ idlePagination.isSuccess = true;
+
+ // Re-render inside the providers once the mocked query flips to success.
+ let bump;
+ const Harness = () => {
+ const [, setTick] = useState(0);
+ bump = () => setTick((n) => n + 1);
+ return ;
+ };
+ const { findByText } = renderWithProviders();
+
+ pendingPermissions.data = loadedPermissions;
+ pendingPermissions.isFetching = false;
+ pendingPermissions.isSuccess = true;
+ act(() => bump());
+
+ expect(await findByText("CIPP.Alert.Read")).toBeTruthy();
+ });
+});
diff --git a/tests/components/CippTable/CippGraphExplorerFilter.test.jsx b/tests/components/CippTable/CippGraphExplorerFilter.test.jsx
index 49df07503eb8..d0d523f268c5 100644
--- a/tests/components/CippTable/CippGraphExplorerFilter.test.jsx
+++ b/tests/components/CippTable/CippGraphExplorerFilter.test.jsx
@@ -197,6 +197,28 @@ describe('CippGraphExplorerFilter', () => {
})
})
+ it('does not echo the selectedPreset prop back to onPresetSelect (remount-echo regression)', async () => {
+ ApiGetCall.mockImplementation(() => ({
+ isSuccess: false,
+ isFetching: false,
+ data: undefined,
+ refetch: vi.fn(),
+ }))
+ const onPresetSelect = vi.fn()
+ renderWithProviders(
+
+ )
+ await waitFor(() => {
+ expect(screen.getByRole('combobox', { name: 'Select a preset' })).toHaveValue('Licensed Users')
+ })
+ expect(onPresetSelect).not.toHaveBeenCalled()
+ })
+
it('switching between two option-shape presets applies the second (dep-array regression)', async () => {
const optionA = { label: BUILTIN.name, value: BUILTIN.id, addedFields: BUILTIN }
const optionB = { label: 'Saved Object Select', value: 'saved-1', addedFields: savedObjectSelect }
diff --git a/tests/theme/mobile-gutters.test.js b/tests/theme/mobile-gutters.test.js
index bf17a4fd4c6b..bca5ad75a4e6 100644
--- a/tests/theme/mobile-gutters.test.js
+++ b/tests/theme/mobile-gutters.test.js
@@ -45,3 +45,30 @@ describe("horizontal gutters on small screens", () => {
expect(content[MOBILE]?.paddingBottom).toBeUndefined();
});
});
+
+// A home-screen (standalone) install on iPhone draws under the status bar because the viewport
+// is viewport-fit=cover. The top nav pads for that itself; everything else that reaches the top
+// edge — fullscreen dialogs and the left/right drawers — gets the inset from the theme.
+describe("status-bar inset on surfaces that reach the top edge", () => {
+ const theme = createTheme({ colorPreset: "orange", contrast: "high", paletteMode: "light" });
+ const INSET = "env(safe-area-inset-top, 0px)";
+ const drawerPaper = (ownerState) => theme.components.MuiDrawer.styleOverrides.paper({ ownerState });
+
+ it("pads fullscreen dialogs", () => {
+ expect(theme.components.MuiDialog.styleOverrides.paperFullScreen.paddingTop).toBe(INSET);
+ });
+
+ it("keeps a tall non-fullscreen phone dialog below the inset without double-padding fullscreen", () => {
+ const rule = theme.components.MuiDialog.styleOverrides.paper[MOBILE]["&:not(.MuiDialog-paperFullScreen)"];
+ expect(rule.marginTop).toBe(INSET);
+ expect(rule.maxHeight).toBe(`calc(100% - ${INSET})`);
+ expect(theme.components.MuiDialog.styleOverrides.paper[MOBILE].maxHeight).toBeUndefined();
+ });
+
+ it("pads temporary left/right drawers only — the permanent side nav already sits under the top nav", () => {
+ expect(drawerPaper({ variant: "temporary", anchor: "left" }).paddingTop).toBe(INSET);
+ expect(drawerPaper({ variant: "temporary", anchor: "right" }).paddingTop).toBe(INSET);
+ expect(drawerPaper({ variant: "temporary", anchor: "bottom" }).paddingTop).toBeUndefined();
+ expect(drawerPaper({ variant: "permanent", anchor: "left" }).paddingTop).toBeUndefined();
+ });
+});
diff --git a/tests/utils/instance-diagnostics.test.js b/tests/utils/instance-diagnostics.test.js
new file mode 100644
index 000000000000..ff66982d8f94
--- /dev/null
+++ b/tests/utils/instance-diagnostics.test.js
@@ -0,0 +1,123 @@
+import {
+ aggregateDiagnosticsClients,
+ sortDiagnosticsChecks,
+ buildClientLogQuery,
+ buildRequestSeries,
+ getCheckLabel,
+ formatBytes,
+} from '../../src/utils/instance-diagnostics'
+
+describe('instance-diagnostics', () => {
+ describe('aggregateDiagnosticsClients', () => {
+ it('sums Count per AppId across buckets and computes share%', () => {
+ const buckets = [
+ { Clients: [{ AppId: 'a', AppName: 'App A', IP: '1.1.1.1', Count: 10 }] },
+ { Clients: [{ AppId: 'a', AppName: 'App A', IP: '1.1.1.1', Count: 5 }, { AppId: 'b', AppName: 'App B', IP: '2.2.2.2', Count: 5 }] },
+ ]
+ const result = aggregateDiagnosticsClients(buckets)
+ expect(result).toEqual([
+ { AppId: 'a', AppName: 'App A', IP: '1.1.1.1', Count: 15, SharePct: 75 },
+ { AppId: 'b', AppName: 'App B', IP: '2.2.2.2', Count: 5, SharePct: 25 },
+ ])
+ })
+
+ it('rounds SharePct to one decimal place', () => {
+ const buckets = [{ Clients: [{ AppId: 'a', Count: 2 }, { AppId: 'b', Count: 1 }] }]
+ const result = aggregateDiagnosticsClients(buckets)
+ expect(result).toEqual([
+ { AppId: 'a', AppName: undefined, IP: undefined, Count: 2, SharePct: 66.7 },
+ { AppId: 'b', AppName: undefined, IP: undefined, Count: 1, SharePct: 33.3 },
+ ])
+ })
+
+ it('handles empty/missing input without throwing', () => {
+ expect(aggregateDiagnosticsClients(undefined)).toEqual([])
+ expect(aggregateDiagnosticsClients([])).toEqual([])
+ expect(aggregateDiagnosticsClients([{}])).toEqual([])
+ })
+ })
+
+ describe('buildRequestSeries', () => {
+ it('splits 5 clients into 4 named series plus Other', () => {
+ const buckets = [
+ {
+ Bucket: '2026-09-10T10:00',
+ Clients: [
+ { AppId: 'a', AppName: 'App A', Count: 50 },
+ { AppId: 'b', AppName: 'App B', Count: 40 },
+ { AppId: 'c', AppName: 'App C', Count: 30 },
+ { AppId: 'd', AppName: 'App D', Count: 20 },
+ { AppId: 'e', AppName: 'App E', Count: 10 },
+ ],
+ },
+ ]
+ const { data, series } = buildRequestSeries(buckets, 4)
+ expect(series).toEqual([
+ { AppId: 'a', AppName: 'App A' },
+ { AppId: 'b', AppName: 'App B' },
+ { AppId: 'c', AppName: 'App C' },
+ { AppId: 'd', AppName: 'App D' },
+ ])
+ expect(data).toEqual([{ Bucket: '2026-09-10T10:00', a: 50, b: 40, c: 30, d: 20, Other: 10 }])
+ })
+
+ it('emits only the series present with no Other key when nothing is left over', () => {
+ const buckets = [
+ {
+ Bucket: '2026-09-10T10:00',
+ Clients: [
+ { AppId: 'a', AppName: 'App A', Count: 5 },
+ { AppId: 'b', AppName: 'App B', Count: 3 },
+ ],
+ },
+ ]
+ const { data, series } = buildRequestSeries(buckets, 4)
+ expect(series).toEqual([
+ { AppId: 'a', AppName: 'App A' },
+ { AppId: 'b', AppName: 'App B' },
+ ])
+ expect(data).toEqual([{ Bucket: '2026-09-10T10:00', a: 5, b: 3 }])
+ })
+ })
+
+ describe('sortDiagnosticsChecks', () => {
+ it('orders FAIL, WARN, INFO, PASS', () => {
+ const checks = [
+ { Check: 'a', Status: 'PASS' },
+ { Check: 'b', Status: 'FAIL' },
+ { Check: 'c', Status: 'INFO' },
+ { Check: 'd', Status: 'WARN' },
+ ]
+ expect(sortDiagnosticsChecks(checks).map((c) => c.Status)).toEqual([
+ 'FAIL',
+ 'WARN',
+ 'INFO',
+ 'PASS',
+ ])
+ })
+ })
+
+ describe('getCheckLabel', () => {
+ it('maps known check ids to readable labels and passes unknown ids through', () => {
+ expect(getCheckLabel('api-clients')).toBe('API clients')
+ expect(getCheckLabel('some-future-check')).toBe('some-future-check')
+ })
+ })
+
+ describe('formatBytes', () => {
+ it('picks the right unit with one decimal place', () => {
+ expect(formatBytes(512)).toBe('512.0 B')
+ expect(formatBytes(2048)).toBe('2.0 KB')
+ expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MB')
+ expect(formatBytes(1.5 * 1024 * 1024 * 1024)).toBe('1.5 GB')
+ })
+ })
+
+ describe('buildClientLogQuery', () => {
+ it('builds a search-all-files KQL query scoped to the AppId and window', () => {
+ expect(buildClientLogQuery('11111111-2222-3333-4444-555555555555', 24)).toBe(
+ 'search all files\n| where Message contains "AppId=11111111-2222-3333-4444-555555555555"\n| where Timestamp > ago(24h)\n| take 1000\n| sort by Timestamp desc'
+ )
+ })
+ })
+})