This preview uses sample data so you can see how your branding lands before running a report against a real tenant.
', - }, - { - id: 'sample-2', - type: 'scorecard', - title: 'Environment Overview', - static: true, - stats: [ - { label: 'Licensed Users', value: '128' }, - { label: 'Devices', value: '96' }, - { label: 'Global Admins', value: '3', caption: 'Target: 2–4' }, - { label: 'Guests', value: '9' }, - ], - }, - { - id: 'sample-3', - type: 'chart', - title: 'Device Compliance', - static: true, - chartKind: 'donut', - chartCentreLabel: 'Devices', - chartData: [ - { label: 'Compliant', value: 78 }, - { label: 'Non-compliant', value: 14 }, - { label: 'Not evaluated', value: 4 }, - ], - }, - { - id: 'sample-4', - type: 'chart', - title: 'Secure Score Trend', - static: true, - chartKind: 'trend', - chartMax: 100, - chartCaption: 'Current: 61 / 100 (61%)', - chartData: [ - { label: 'Jul 29', value: 50 }, - { label: 'Jul 31', value: 55 }, - { label: 'Aug 2', value: 58 }, - { label: 'Aug 4', value: 61 }, - ], - }, - { - id: 'sample-5', - type: 'progress', - title: 'Control Coverage', - static: true, - items: [ - { label: 'MFA enforced', value: 92, max: 100 }, - { label: 'Disk encryption', value: 78, max: 100 }, - { label: 'Defender onboarded', value: 64, max: 100 }, - ], - }, - { id: 'sample-6', type: 'pagebreak', title: '', static: true }, - { - id: 'sample-7', - type: 'hero', - title: 'seconds', - static: true, - heroHighlight: '39', - heroSubText: 'a business falls victim to ransomware', - heroFooterText: 'Proactive defense beats reactive recovery', - heroImage: '/reportImages/working.jpg', - }, - { - id: 'sample-8', - type: 'test', - title: 'Multi-factor authentication', - status: 'Failed', - static: false, - content: - '14 of 128 accounts can still sign in without a second factor.\n\n## Results\n\n| Account | Method | Last sign-in |\n| --- | --- | --- |\n| sample.one@example.com | None | 2 days ago |\n| sample.two@example.com | None | 9 days ago |\n', - }, - { - id: 'sample-9', - type: 'chart', - title: 'Devices by Platform', - static: true, - chartKind: 'bar', - chartData: [ - { label: 'Windows', value: 62 }, - { label: 'macOS', value: 18 }, - { label: 'iOS', value: 12 }, - { label: 'Android', value: 4 }, - ], - }, - { - // A database block in its markdown form: the query result arrives as a markdown table, which is - // the path that exercises the table renderer and the long-value wrapping in it. The deliberately - // over-long identifier is there to show a value being broken across lines rather than running - // out of its column. - id: 'sample-10', - type: 'database', - title: 'Query Results', - static: true, - format: 'text', - content: - '| Policy | Identifier | State |\n| --- | --- | --- |\n| Baseline | 8f2a1c4e-6b3d-4f5a-9e7c-1d2b3a4c5e6f | Enabled |\n| Hardened | Microsoft_Defender_for_Business_Servers | Report only |\n', - }, - { - // The same block type in its raw form, which renders as a code block instead. - id: 'sample-11', - type: 'database', - title: 'Raw Response', - static: true, - format: 'json', - content: '{\n "tenant": "contoso.com",\n "policies": 191,\n "enabled": 5\n}', - }, -] - -/** - * Shadow AI report. - * - * Field names match what the report reads — `tools` not `count`, `aiTool` not `name`, and a `status` - * of exactly 'Sanctioned' on at least one app, which is what builds the Company Sanctioned AI Tools - * page. The earlier sample invented its own names, so every table in the preview rendered its empty - * state and the sanctioned page never appeared at all. - */ -export const SAMPLE_SHADOW_AI = { - summary: { - aiToolsDetected: 18, - deviceInstalls: 36, - consentedAiApps: 5, - highRiskTools: 3, - sanctionedTools: 2, - }, - byRisk: [ - { risk: 'High', tools: 3 }, - { risk: 'Medium', tools: 7 }, - { risk: 'Low', tools: 6 }, - { risk: 'Informational', tools: 2 }, - ], - topTools: [ - { tool: 'Sample AI Assistant', category: 'Chat', status: 'Unsanctioned', devices: 22, users: 18 }, - { tool: 'Sample Code Helper', category: 'Development', status: 'Unsanctioned', devices: 14, users: 9 }, - { tool: 'Sample Notetaker', category: 'Meetings', status: 'Sanctioned', devices: 11, users: 24 }, - ], - detectedApps: [ - { - application: 'Sample AI Assistant Desktop', - aiTool: 'Sample AI Assistant', - vendor: 'Example Corp', - category: 'Chat', - risk: 'High', - status: 'Unsanctioned', - deviceCount: 22, - }, - { - application: 'Sample Code Helper', - aiTool: 'Sample Code Helper', - vendor: 'Example Labs', - category: 'Development', - risk: 'Medium', - status: 'Unsanctioned', - deviceCount: 14, - }, - { - application: 'Sample Notetaker', - aiTool: 'Sample Notetaker', - vendor: 'Example Corp', - category: 'Meetings', - risk: 'Informational', - status: 'Sanctioned', - deviceCount: 11, - }, - ], - consentedApps: [ - { - applicationId: '00000000-0000-0000-0000-000000000001', - application: 'Sample AI Connector', - aiTool: 'Sample AI Assistant', - vendor: 'Example Corp', - category: 'Chat', - risk: 'High', - status: 'Unsanctioned', - activeUsersLast7Days: 18, - firstConsentedDateTime: '2026-06-11T10:22:00Z', - }, - { - applicationId: '00000000-0000-0000-0000-000000000002', - application: 'Sample Meeting Notes', - aiTool: 'Sample Notetaker', - vendor: 'Example Corp', - category: 'Meetings', - risk: 'Informational', - status: 'Sanctioned', - activeUsersLast7Days: 24, - firstConsentedDateTime: '2026-03-02T14:05:00Z', - }, - ], -} - -/** BEC remediation report. Field shapes mirror the real Push-BECRun payload so the preview - * renders every report section with plausible values rather than 'Unknown' placeholders. */ export const SAMPLE_BEC = { - userData: { displayName: 'Sample User', userPrincipalName: 'sample.user@example.com' }, + userData: { + displayName: 'Sample User', + userPrincipalName: 'sample.user@example.com', + }, becData: { ExtractedAt: '2026-08-05T09:00:00Z', ExtractResult: 'Successfully extracted logs from auditlog', AnalysisWindowDays: 7, + CaseId: 'BEC-20260805090000-a1b2c3', + ContentPolicy: 'metadata-only', + // Server-side score: the report prefers this over its own calculation when present + Score: { + Value: 19, + Level: 'High', + Thresholds: { High: 7, Medium: 4 }, + Breakdown: [ + { + Signal: 'NewRules', + Description: 'Inbox rules exist on the mailbox', + Weight: 3, + Count: 1, + Applied: true, + }, + { + Signal: 'InboxRuleChanges', + Description: + 'Inbox rules were created, changed or removed in the window', + Weight: 3, + Count: 1, + Applied: true, + }, + { + Signal: 'SuspiciousRules', + Description: 'An inbox rule moves mail to a RSS folder', + Weight: 5, + Count: 1, + Applied: true, + }, + { + Signal: 'MaliciousApps', + Description: 'Applications match the known-malicious catalog', + Weight: 5, + Count: 1, + Applied: true, + }, + { + Signal: 'ForeignActivity', + Description: + 'Rule, safelist, sharing or mail activity from outside the usage location', + Weight: 3, + Count: 2, + Applied: true, + }, + { + Signal: 'AnonymousLinks', + Description: 'Anonymous sharing links were created or changed', + Weight: 3, + Count: 0, + Applied: false, + }, + ], + Version: 2, + }, + Completeness: { + AuditLog: { Complete: true, Cap: null, Error: null, Count: 2 }, + SignIns: { Complete: true, Cap: null, Error: null, Count: 3 }, + SentMessages: { + Complete: false, + Cap: '5 pages of 5000 rows', + Error: null, + Count: 25000, + }, + }, NewRules: [ { Name: 'Sample forwarding rule', - Description: 'Move messages from billing@example.com to folder RSS Feeds', + Description: + 'Move messages from billing@example.com to folder RSS Feeds', MoveToFolder: 'RSS Feeds', RecentlyChanged: true, }, @@ -467,7 +177,8 @@ export const SAMPLE_BEC = { }, MFADevices: [ { - '@odata.type': '#microsoft.graph.microsoftAuthenticatorAuthenticationMethod', + '@odata.type': + '#microsoft.graph.microsoftAuthenticatorAuthenticationMethod', displayName: 'Sample phone', createdDateTime: '2026-08-03T12:00:00Z', }, @@ -501,7 +212,8 @@ export const SAMPLE_BEC = { Date: '2026-08-04T10:15:00Z', Workload: 'OneDrive', FileName: 'Payroll Q3.xlsx', - ItemUrl: 'https://example-my.sharepoint.com/personal/sample_user/Documents/Payroll Q3.xlsx', + ItemUrl: + 'https://example-my.sharepoint.com/personal/sample_user/Documents/Payroll Q3.xlsx', Target: null, TargetType: null, ClientIP: '203.0.113.10', @@ -563,82 +275,22 @@ export const SAMPLE_BEC = { }, } -/** SharePoint sharing report. */ export const SAMPLE_SHARING = { summary: { totalLinks: 24, itemsShared: 18, externalRecipients: 6, - anonymousLinks: 4, - anonymousEditLinks: 1, - neverExpiringAnonymous: 2, - folderShares: 3, - externalLinks: 6, - sharePointSites: 5, - teamsSites: 3, - oneDriveAccounts: 12, }, - links: [ - { - itemName: 'Sample Proposal.docx', - siteName: 'Sample Marketing', - linkType: 'Anonymous', - scope: 'Edit', - expires: 'Never', - recipients: 'Anyone with the link', - }, - { - itemName: 'Sample Budget.xlsx', - siteName: 'Sample Finance', - linkType: 'External', - scope: 'View', - expires: '2026-12-31', - recipients: 'partner@example.com', - }, - ], - topRecipients: [ - { recipient: 'partner@example.com', links: 5 }, - { recipient: 'supplier@example.net', links: 3 }, - ], - topLibraries: [ - { library: 'Sample Marketing / Documents', links: 9 }, - { library: 'Sample Finance / Documents', links: 6 }, - ], } -/** SharePoint permissions report. */ export const SAMPLE_PERMISSIONS = { summary: { + totalAssignments: 156, sitesScanned: 12, librariesScanned: 34, - totalAssignments: 156, - broadClaimGrants: 2, - externalGrants: 5, - directFullControlGrants: 3, - uniquePermissionLibraries: 7, }, - assignments: [ - { - siteName: 'Sample Marketing', - libraryName: 'Documents', - principal: 'Everyone except external users', - permission: 'Edit', - type: 'Broad claim', - }, - { - siteName: 'Sample Finance', - libraryName: 'Documents', - principal: 'partner@example.com', - permission: 'Full Control', - type: 'External', - }, - ], } -/** - * Exchange mail flow report. Fourteen days of daily disposition counts, shaped as the page hands - * them over: totals per event type, direction totals, and the per-day rows behind them. - */ export const SAMPLE_MAIL_FLOW = { days: 14, totals: { @@ -649,55 +301,11 @@ export const SAMPLE_MAIL_FLOW = { EmailPhish: 412, EmailMalware: 37, }, - directionTotals: { - Inbound: 39280, - Outbound: 8940, - IntraOrg: 11879, - }, - daily: [ - { date: '2026-08-04', GoodMail: 3410, TransportRules: 96, SpamDetections: 430, EdgeBlockSpam: 281, EmailPhish: 29, EmailMalware: 3 }, - { date: '2026-08-05', GoodMail: 3688, TransportRules: 104, SpamDetections: 468, EdgeBlockSpam: 302, EmailPhish: 33, EmailMalware: 2 }, - { date: '2026-08-06', GoodMail: 3572, TransportRules: 88, SpamDetections: 451, EdgeBlockSpam: 295, EmailPhish: 31, EmailMalware: 4 }, - { date: '2026-08-07', GoodMail: 3740, TransportRules: 112, SpamDetections: 502, EdgeBlockSpam: 318, EmailPhish: 38, EmailMalware: 1 }, - { date: '2026-08-08', GoodMail: 3495, TransportRules: 97, SpamDetections: 476, EdgeBlockSpam: 304, EmailPhish: 35, EmailMalware: 3 }, - { date: '2026-08-09', GoodMail: 1180, TransportRules: 21, SpamDetections: 268, EdgeBlockSpam: 174, EmailPhish: 12, EmailMalware: 0 }, - { date: '2026-08-10', GoodMail: 1042, TransportRules: 18, SpamDetections: 251, EdgeBlockSpam: 166, EmailPhish: 10, EmailMalware: 1 }, - { date: '2026-08-11', GoodMail: 3820, TransportRules: 118, SpamDetections: 529, EdgeBlockSpam: 341, EmailPhish: 41, EmailMalware: 5 }, - { date: '2026-08-12', GoodMail: 3903, TransportRules: 121, SpamDetections: 544, EdgeBlockSpam: 352, EmailPhish: 44, EmailMalware: 4 }, - { date: '2026-08-13', GoodMail: 3766, TransportRules: 109, SpamDetections: 511, EdgeBlockSpam: 329, EmailPhish: 36, EmailMalware: 2 }, - { date: '2026-08-14', GoodMail: 3841, TransportRules: 114, SpamDetections: 498, EdgeBlockSpam: 321, EmailPhish: 34, EmailMalware: 3 }, - { date: '2026-08-15', GoodMail: 3612, TransportRules: 102, SpamDetections: 470, EdgeBlockSpam: 303, EmailPhish: 30, EmailMalware: 4 }, - { date: '2026-08-16', GoodMail: 1214, TransportRules: 22, SpamDetections: 264, EdgeBlockSpam: 172, EmailPhish: 20, EmailMalware: 3 }, - { date: '2026-08-17', GoodMail: 1127, TransportRules: 18, SpamDetections: 258, EdgeBlockSpam: 322, EmailPhish: 19, EmailMalware: 2 }, - ], - topSenders: [ - { Name: 'notifications@sample-crm.example.com', Count: 4820 }, - { Name: 'billing@example.com', Count: 3115 }, - { Name: 'scanner-3f@example.com', Count: 2064 }, - { Name: 'sample.user@example.com', Count: 1893 }, - { Name: 'helpdesk@example.com', Count: 1477 }, - ], - topSpamRecipients: [ - { Name: 'info@example.com', Count: 1840 }, - { Name: 'sales@example.com', Count: 1226 }, - { Name: 'sample.user@example.com', Count: 744 }, - { Name: 'accounts@example.com', Count: 517 }, - { Name: 'careers@example.com', Count: 388 }, - ], } -/** - * Which sample set feeds which report, keyed by the ids in REPORT_COVER_PRESETS so the preview's - * report picker and its data stay in step. - */ -export const SAMPLE_DATA_BY_REPORT = { - // The executive report can append the Shadow AI pages, so it is given the same sample the Shadow - // AI report uses. Without it that whole section was silently absent from the preview. - executive: { ...SAMPLE_EXECUTIVE, shadowAIData: SAMPLE_SHADOW_AI }, - reportBuilder: { blocks: SAMPLE_REPORT_BUILDER_BLOCKS }, - shadowAI: { data: SAMPLE_SHADOW_AI }, - bec: SAMPLE_BEC, - sharing: { sharingData: SAMPLE_SHARING }, - permissions: { permissionsData: SAMPLE_PERMISSIONS }, - mailFlow: { mailFlowData: SAMPLE_MAIL_FLOW }, +export const SAMPLE_LICENSING = { + licensedUsers: 96, + plans: 11, + monthlySpend: 2433.75, + potentialAnnual: 10059.6, } diff --git a/src/components/CippPdf/reportPdfStyles.js b/src/components/CippPdf/reportPdfStyles.js index 504a97574c4a..4242801c353b 100644 --- a/src/components/CippPdf/reportPdfStyles.js +++ b/src/components/CippPdf/reportPdfStyles.js @@ -1,4 +1,3 @@ -import { StyleSheet } from '@react-pdf/renderer' import { asReportTheme, DEFAULT_BRAND_COLOUR, REPORT_COLOURS, withAlpha } from './reportTheme' // The @react-pdf/renderer style system shared by CIPP's client-facing PDF reports: a cover page, @@ -28,8 +27,7 @@ export const PAGE_ORIENTATIONS = [ export const DEFAULT_PAGE_SETUP = { size: 'A4', orientation: 'portrait' } -// Paper widths in points, matching the names above. Needed because a table has to know how wide its -// columns actually are to wrap a long value without a hyphen — see measureText.js. +// Paper widths in points, matching the names above; the report builder sizes its page setup off these. const PAGE_WIDTHS = { A4: 595.28, LETTER: 612, LEGAL: 612, A3: 841.89, A5: 419.53 } const PAGE_HEIGHTS = { A4: 841.89, LETTER: 792, LEGAL: 1008, A3: 1190.55, A5: 595.28 } @@ -87,7 +85,7 @@ export const createReportStyles = (themeOrColour = DEFAULT_BRAND_COLOUR) => { // header band, which are brand surfaces rather than any one content role. const brandColor = theme.primary - return StyleSheet.create({ + return ({ page: { flexDirection: 'column', backgroundColor: '#FFFFFF', diff --git a/src/components/CippPdf/reportTheme.js b/src/components/CippPdf/reportTheme.js index 91c74dd1b629..49a1414875a4 100644 --- a/src/components/CippPdf/reportTheme.js +++ b/src/components/CippPdf/reportTheme.js @@ -306,9 +306,9 @@ export const buildPalette = (branding, { primary, secondary }) => { * footer might want — `%tenantname%` foremost — is already a CIPP variable, so it is not restated * here. The `report` prefix keeps these clear of the reserved names in Get-CIPPTextReplacement. * - * A PDF renders in the browser, so Get-CIPPTextReplacement never sees this text. `useReportVariables` - * supplies the resolved values instead. Being a CIPP variable is about where it is documented and - * offered, not about who substitutes it. + * The PDFs render server-side, where ConvertTo-CippReportPdf runs the branding text through + * Get-CIPPTextReplacement; the branding editor's cover mock substitutes the two report tokens itself. + * Being a CIPP variable is about where it is documented and offered, not about who substitutes it. */ export const REPORT_VARIABLES = [ { value: '%reportname%', label: 'Report name' }, @@ -322,8 +322,8 @@ export const REPORT_VARIABLES = [ * and an unknown token is left as written rather than blanked — that is what tells whoever * configured it that they mistyped, instead of silently swallowing it. * - * Given the values rather than looking them up: the report's own tokens plus whatever - * `useReportVariables` resolved for the tenant. + * Given the values rather than looking them up: the report's own tokens plus any tenant values the + * caller already holds. */ export const applyReportVariables = (template, variables = {}) => { if (!template) return '' diff --git a/src/components/CippPdf/useBrandingSettings.js b/src/components/CippPdf/useBrandingSettings.js index 95872bb9b79f..a63f5f44215b 100644 --- a/src/components/CippPdf/useBrandingSettings.js +++ b/src/components/CippPdf/useBrandingSettings.js @@ -21,12 +21,16 @@ export const DEFAULT_BRANDING = Object.freeze({ logoUploads: [], coverImage: null, coverUploads: [], + // The gallery covers by id and name, for pickers that offer them (the report builder's Infographic). + coverImages: [], footerText: '', coverFooterText: '', showFooter: true, showPageNumbers: true, watermarkText: '', watermarkEnabled: true, + // Which of the tenant's names a report prints: 'alias' (the name CIPP shows), 'name' or 'domain'. + tenantLabel: 'alias', reportDefaults: {}, roleColours: {}, }) diff --git a/src/components/CippPdf/useServerPdf.jsx b/src/components/CippPdf/useServerPdf.jsx new file mode 100644 index 000000000000..2c552ff4f471 --- /dev/null +++ b/src/components/CippPdf/useServerPdf.jsx @@ -0,0 +1,125 @@ +import { useEffect, useState } from 'react' +import { Box, CircularProgress, Typography } from '@mui/material' + +const requestInit = (body) => + body + ? { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + } + : { credentials: 'same-origin' } + +/** + * Fetch a server-rendered PDF as a Blob (GET, or POST when `body` is given); rejects with the HTTP status. + * An aborted `signal` drops the request, so the server can stop rendering a PDF nobody will see. + */ +export const fetchServerPdf = (url, body, signal) => + fetch(url, { ...requestInit(body), signal }).then((res) => + res.ok ? res.blob() : Promise.reject(res.status) + ) + +const saveUrl = (href, fileName) => { + const link = document.createElement('a') + link.href = href + link.download = fileName + document.body.appendChild(link) + link.click() + document.body.removeChild(link) +} + +/** Render on the server and save the result straight to a file, without a preview. */ +export const downloadServerPdf = (url, body, fileName) => + fetchServerPdf(url, body).then((blob) => { + const objectUrl = URL.createObjectURL(blob) + saveUrl(objectUrl, fileName) + URL.revokeObjectURL(objectUrl) + }) + +const idle = { pdfUrl: '', loading: false, error: null } + +/** + * Fetches a server-rendered PDF as an object URL for an iframe, re-fetching when the request changes + * and revoking the URL on change or unmount. `enabled` gates the fetch, so a dialog only renders while + * open. `error` is the HTTP status of a failed fetch (0 for a network failure), else null. + */ +export const useServerPdf = ({ url, body, enabled = true }) => { + const [state, setState] = useState(idle) + const requestKey = enabled ? JSON.stringify({ url, body }) : '' + + useEffect(() => { + if (!requestKey) { + setState((prev) => (prev === idle ? prev : idle)) + return undefined + } + let objectUrl + let cancelled = false + const controller = new AbortController() + setState({ pdfUrl: '', loading: true, error: null }) + fetchServerPdf(url, body, controller.signal) + .then((blob) => { + if (cancelled) return + objectUrl = URL.createObjectURL(blob) + setState({ pdfUrl: objectUrl, loading: false, error: null }) + }) + .catch((status) => { + if (!cancelled) + setState({ + pdfUrl: '', + loading: false, + error: typeof status === 'number' ? status : 0, + }) + }) + return () => { + cancelled = true + controller.abort() + if (objectUrl) URL.revokeObjectURL(objectUrl) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [requestKey]) + + return { + ...state, + download: (fileName) => state.pdfUrl && saveUrl(state.pdfUrl, fileName), + } +} + +const centred = { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + gap: 2, + p: 4, + textAlign: 'center', +} + +/** The preview pane for a server-rendered PDF: spinner while rendering, `errorText` on failure, else the iframe. */ +export const ServerPdfPane = ({ pdfUrl, loading, error, errorText, title }) => { + if (loading) { + return ( +$null, $false, empty string, or{' '}
- @()
+ $null, $false, empty
+ string, or @()
CIPPStatus (Passed/
- Failed/Info/Investigate),{' '}
- CIPPResults, and optional{' '}
- CIPPResultMarkdown to control status and rendering directly (Auto
- result mode only)
+ CIPPStatus (
+ Passed/Failed/Info/
+ Investigate), CIPPResults, and
+ optional CIPPResultMarkdown to control status
+ and rendering directly (Auto result mode only)
New-Object, {'[pscustomobject]@{}'} casts, and
+ New-Object,{' '}
+ {'[pscustomobject]@{}'} casts, and
.NET/reflection are blocked. Build rows with{' '}
- {'Select-Object @{Name;Expression}'} and return a plain{' '}
- {'@{}'} hashtable. Data access is tenant-locked — do not pass{' '}
- -TenantFilter. Type % for replacement variables.
+ {'Select-Object @{Name;Expression}'} and return
+ a plain {'@{}'} hashtable. Data access is
+ tenant-locked — do not pass -TenantFilter. Type{' '}
+ % for replacement variables.
Get-CIPPTestData with -Type.{' '}
- Tenant is auto-locked — do not pass -TenantFilter. Use{' '}
- %variable% syntax for replacement variables.
+ mb: 0.5,
+ }}
+ >
+ Read-only via Get-CIPPTestData with{' '}
+ -Type. Tenant is auto-locked — do not pass{' '}
+ -TenantFilter. Use %variable%{' '}
+ syntax for replacement variables.
CIPPStatus, CIPPResults, and{' '}
+ mb: 1,
+ }}
+ >
+ Lists all users with licenses, resolves SKU IDs to friendly
+ names using the license cache, and returns a markdown table
+ with an explicit Passed status. Demonstrates{' '}
+ CIPPStatus, CIPPResults, and{' '}
CIPPResultMarkdown.
Info status so results are always informational rather
- than a hard fail.
+ mb: 1,
+ }}
+ >
+ Checks user registration details for accounts that
+ haven't registered any MFA method. Uses Info{' '}
+ status so results are always informational rather than a hard
+ fail.
param with a default so the threshold is configurable via Test
- Parameters. Simple auto-detection — empty result = pass, non-empty = fail.
+ mb: 1,
+ }}
+ >
+ Identifies guest accounts that haven't signed in within
+ 90 days. Uses a param with a default so the
+ threshold is configurable via Test Parameters. Simple
+ auto-detection — empty result = pass, non-empty = fail.
Group-Object, building a multi-section
- markdown report, and %tenantname% replacement variables. Always
- passes since it's informational.
+ mb: 1,
+ }}
+ >
+ Provides an informational summary of all Conditional Access
+ policies grouped by state. Demonstrates using{' '}
+ Group-Object, building a multi-section markdown
+ report, and %tenantname% replacement variables.
+ Always passes since it's informational.
{'{{'} to use schema tokens.
% to insert replacement variables (e.g.{' '}
- %tenantid%, %defaultdomain%, or custom variables).
+ %tenantid%, %defaultdomain%, or
+ custom variables).
{'Select-Object @{Name;Expression}'} (not{' '}
{'[pscustomobject]@{}'}) and return a{' '}
- {'@{ CIPPStatus = ... }'} hashtable. New-Object and
- .NET reflection are blocked.
+ {'@{ CIPPStatus = ... }'} hashtable.{' '}
+ New-Object and .NET reflection are blocked.
-TenantFilter is not needed — data access functions are
- automatically locked to the execution tenant. Remove{' '}
- -TenantFilter $TenantFilter from your calls.
+ -TenantFilter is not needed — data access
+ functions are automatically locked to the execution
+ tenant. Remove -TenantFilter $TenantFilter{' '}
+ from your calls.