diff --git a/package.json b/package.json index 0083356427c2..aedbd44fc940 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cipp", - "version": "10.10.3", + "version": "11.0.0", "author": "CIPP Contributors", "homepage": "https://cipp.app/", "bugs": { @@ -65,7 +65,7 @@ "@tiptap/starter-kit": "^3.31.3", "@vvo/tzdb": "^6.198.0", "apexcharts": "7.1.0", - "axios": "1.18.1", + "axios": "1.20.0", "date-fns": "4.4.0", "diff": "^9.0.0", "dompurify": "^3.4.12", @@ -89,11 +89,11 @@ "numeral": "2.0.6", "prop-types": "15.8.1", "punycode": "^2.3.1", - "react": "19.2.8", + "react": "19.3.0", "react-apexcharts": "2.1.1", "react-beautiful-dnd": "13.1.1", - "react-dom": "19.2.8", - "react-dropzone": "20.0.0", + "react-dom": "19.3.0", + "react-dropzone": "20.1.2", "react-error-boundary": "^6.1.2", "react-hook-form": "^7.76.1", "react-html-parser": "^2.0.2", @@ -107,7 +107,7 @@ "react-syntax-highlighter": "^16.1.0", "react-time-ago": "^7.3.3", "react-virtuoso": "^4.18.7", - "recharts": "^3.8.1", + "recharts": "^3.10.1", "redux": "5.0.1", "redux-persist": "^6.0.0", "rehype-raw": "^7.0.0", @@ -124,27 +124,27 @@ "devDependencies": { "@storybook/addon-a11y": "10.3.5", "@storybook/addon-docs": "10.5.10", - "@storybook/addon-themes": "10.3.5", + "@storybook/addon-themes": "10.5.10", "@storybook/addon-vitest": "10.3.5", "@storybook/react-vite": "10.3.5", "@svgr/webpack": "8.1.0", "@testing-library/dom": "10.4.1", "@testing-library/jest-dom": "6.9.1", "@testing-library/react": "16.3.2", - "@testing-library/user-event": "14.6.1", - "@types/react": "19.2.14", - "@types/react-dom": "19.2.5", + "@testing-library/user-event": "14.6.7", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", "@vitest/browser-playwright": "4.1.11", "@vitest/coverage-v8": "4.1.11", "eslint": "^9.39.4", - "eslint-config-next": "^16.3.4", + "eslint-config-next": "^16.3.5", "eslint-config-prettier": "^10.1.8", "jsdom": "29.0.1", "msw": "2.15.0", - "msw-storybook-addon": "3.0.0", + "msw-storybook-addon": "3.0.3", "playwright": "1.63.0", "prettier": "^3.9.6", - "storybook": "10.5.10", + "storybook": "10.6.0", "typescript": "5.9.3", "vite": "8.2.2", "vitest": "4.1.11" diff --git a/public/version.json b/public/version.json index feed56cacfd3..0d0e3ba17009 100644 --- a/public/version.json +++ b/public/version.json @@ -1,3 +1,3 @@ { - "version": "10.10.3" -} \ No newline at end of file + "version": "11.0.0" +} diff --git a/src/api/ApiCall.jsx b/src/api/ApiCall.jsx index 08c492129a0e..973b0610e515 100644 --- a/src/api/ApiCall.jsx +++ b/src/api/ApiCall.jsx @@ -25,6 +25,11 @@ const getRetryAfterMs = (error) => { const retryDelayWithRetryAfter = (failureCount, error) => getRetryAfterMs(error) ?? Math.min(1000 * 2 ** failureCount, 30000); +// A request the user cancelled (navigated away / hit Cancel) aborts the axios signal and surfaces as +// a CanceledError. That is expected, not a failure: never retry it and never raise an error toast. +const isCanceledError = (error) => + error?.code === "ERR_CANCELED" || error?.name === "CanceledError"; + export function ApiGetCall(props) { const { url, @@ -50,6 +55,7 @@ export function ApiGetCall(props) { const MAX_RETRIES = retry; const HTTP_STATUS_TO_NOT_RETRY = [302, 401, 403, 404, 500]; const retryFn = (failureCount, error) => { + if (isCanceledError(error)) return false; let returnRetry = true; if (failureCount >= MAX_RETRIES) { returnRetry = false; @@ -191,7 +197,31 @@ export function ApiPostCall({ relatedQueryKeys, onResult }) { const mutation = useMutation({ mutationFn: async (props) => { - const { url, data, bulkRequest } = props; + const { url, data, bulkRequest, followUps } = props; + if (followUps?.length) { + // A failed primary request throws as usual and nothing else is sent. Once it has + // succeeded, a failed follow-up is reported next to it rather than raised, so the + // primary's results are never hidden behind a follow-up error. + const primary = await axios.post(url, data, { headers: await buildVersionedHeaders() }); + if (onResult) { + onResult(primary.data); + } + const results = [primary.data]; + for (const followUp of followUps) { + try { + const response = await axios.post(followUp.url, followUp.data, { + headers: await buildVersionedHeaders(), + }); + results.push(response.data); + } catch (error) { + results.push({ + Results: + error.response?.data?.Results ?? `Failed ${followUp.url}: ${error.message}`, + }); + } + } + return results; + } if (bulkRequest && Array.isArray(data)) { const results = []; for (let i = 0; i < data.length; i++) { @@ -278,6 +308,7 @@ export function ApiGetCallWithPagination({ const HTTP_STATUS_TO_NOT_RETRY = [302, 401, 403, 404, 500]; const retryFn = (failureCount, error) => { + if (isCanceledError(error)) return false; let returnRetry = true; if (failureCount >= MAX_RETRIES) { returnRetry = false; diff --git a/src/components/BECRemediationReportButton.jsx b/src/components/BECRemediationReportButton.jsx index 7cb7b06a2c9e..b9f1be3243a8 100644 --- a/src/components/BECRemediationReportButton.jsx +++ b/src/components/BECRemediationReportButton.jsx @@ -8,1224 +8,40 @@ import { DialogContent, DialogActions, Box, + Stack, + ToggleButton, + ToggleButtonGroup, Typography, IconButton, - CircularProgress, } from '@mui/material' -import { PDFDownloadLink } from '@react-pdf/renderer' -import { CippPdfPreview } from './CippPdf/CippPdfPreview' -import { useReportVariables } from './CippPdf/useReportVariables' -import { useBrandingSettings } from './CippPdf/useBrandingSettings' -import { - AlertBox, - Bold, - Bullet, - BulletList, - ClearBox, - ContentPage, - CoverMeta, - InfoBox, - Note, - Paragraph, - ReportDocument, - Section, - StatRow, -} from './CippPdf' - -// BEC Remediation PDF Document Component -// Exported so the branding preview can render this report against sample data, and so tests can -// render it to a real PDF. -export const BECRemediationReportDocument = ({ - userData, - becData, - brandingSettings, - tenantName, - remediationData, - variables, -}) => { - const currentDate = new Date().toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - }) - - // Helper function to format dates - const formatDate = (dateString) => { - if (!dateString) return 'N/A' - try { - return new Date(dateString).toLocaleString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }) - } catch { - return dateString - } - } - - const formatSafelistValue = (value) => { - if (!value) return 'unchanged' - return Array.isArray(value) ? value.join(', ') || 'unchanged' : String(value) - } - - // Calculate statistics - const stats = { - newRules: becData?.NewRules?.length || 0, - ruleChanges: becData?.InboxRuleChanges?.length || 0, - newUsers: becData?.NewUsers?.length || 0, - newApps: becData?.AddedApps?.length || 0, - permissionChanges: becData?.MailboxPermissionChanges?.length || 0, - permissionChangesTargetingUser: (becData?.MailboxPermissionChanges || []).filter( - (change) => change?.TargetsSuspect === true - ).length, - mfaDevices: becData?.MFADevices?.length || 0, - passwordChanges: becData?.ChangedPasswords?.length || 0, - sentMessages: becData?.SentMessages?.length || 0, - trustedSenders: becData?.TrustedSenders?.length || 0, - blockedSenders: becData?.BlockedSenders?.length || 0, - safelistChanges: becData?.SafelistChanges?.length || 0, - sharingChanges: becData?.SharingChanges?.length || 0, - anonymousLinks: (becData?.SharingChanges || []).filter((c) => - c?.Operation?.startsWith('AnonymousLink') - ).length, - intuneDevices: becData?.IntuneDevices?.length || 0, - signIns: becData?.SuspectUserSignIns?.length || 0, - sentTotalMessages: becData?.SentMessageAnalysis?.TotalMessages ?? 0, - sentTotalRecipients: becData?.SentMessageAnalysis?.TotalRecipients ?? 0, - repeatedSubjects: becData?.SentMessageAnalysis?.FlaggedSubjectCount || 0, - sendBursts: becData?.SentMessageAnalysis?.Bursts?.length || 0, - massMailFlagged: becData?.SentMessageAnalysis?.Flagged === true, - maliciousApps: - (becData?.AddedApps || []).filter((app) => app?.MaliciousMatch).length + - (becData?.MaliciousSPs?.length || 0), - } - - const locationAnalysis = becData?.LocationAnalysis - stats.foreignSignIns = locationAnalysis?.ForeignSignInCount || 0 - stats.foreignSuccessfulSignIns = locationAnalysis?.ForeignSuccessfulSignInCount || 0 - stats.foreignSentMessages = locationAnalysis?.ForeignSentMessageCount || 0 - stats.foreignActivity = - (locationAnalysis?.ForeignRuleChangeCount || 0) + - (locationAnalysis?.ForeignSafelistChangeCount || 0) + - (locationAnalysis?.ForeignSharingChangeCount || 0) + - (locationAnalysis?.ForeignSentMessageCount || 0) - - // the analysis window: 7 days before the data was extracted - const analysisWindowStart = (() => { - const extractedAt = becData?.ExtractedAt ? new Date(becData.ExtractedAt) : new Date() - if (Number.isNaN(extractedAt.getTime())) { - return new Date(new Date().getTime() - 7 * 24 * 60 * 60 * 1000) - } - return new Date(extractedAt.getTime() - 7 * 24 * 60 * 60 * 1000) - })() - - const recentIntuneDevices = (becData?.IntuneDevices || []).filter((device) => { - if (!device?.enrolledDateTime) return false - const enrolled = new Date(device.enrolledDateTime) - if (Number.isNaN(enrolled.getTime())) return false - return enrolled >= analysisWindowStart - }) - stats.recentIntuneDevices = recentIntuneDevices.length - - const isRecentMfaDevice = (method) => { - if (!method?.createdDateTime) return false - const created = new Date(method.createdDateTime) - if (Number.isNaN(created.getTime())) return false - return created >= analysisWindowStart - } - stats.recentMfaDevices = (becData?.MFADevices || []).filter(isRecentMfaDevice).length - - // successful foreign sign-ins first - they prove access, failed ones are mostly spray noise - const foreignSignIns = (becData?.SuspectUserSignIns || []) - .filter((signIn) => signIn?.ForeignLocation === true) - .sort((a, b) => (b?.Status === 'Success') - (a?.Status === 'Success')) - - const sortedIntuneDevices = [...(becData?.IntuneDevices || [])].sort((a, b) => { - const aTime = a?.enrolledDateTime ? new Date(a.enrolledDateTime).getTime() : 0 - const bTime = b?.enrolledDateTime ? new Date(b.enrolledDateTime).getTime() : 0 - return bTime - aTime - }) - - // Determine threat level - const calculateThreatLevel = () => { - let threatScore = 0 - if (stats.newRules > 0) threatScore += 3 - if (stats.ruleChanges > 0) threatScore += 3 - // A change to this mailbox's permissions outweighs unrelated tenant churn, which the - // tenant-wide search also surfaces - if (stats.permissionChangesTargetingUser > 0) threatScore += 2 - else if (stats.permissionChanges > 0) threatScore += 1 - // Generic new service principals appear constantly; the actually-bad ones score +5 below - if (stats.newApps > 0) threatScore += 1 - if (stats.newUsers > 5) threatScore += 1 - if (stats.safelistChanges > 0) threatScore += 2 - - // Check for suspicious rules (RSS folder moves) - const hasSuspiciousRules = becData?.NewRules?.some((rule) => rule.MoveToFolder?.includes('RSS')) - if (hasSuspiciousRules) threatScore += 5 - - // A catalog-matched application is a confirmed bad indicator, not a heuristic - if (stats.maliciousApps > 0) threatScore += 5 - // Only a successful foreign sign-in proves access - failed foreign attempts are - // password-spray background noise present on almost every tenant - if (stats.foreignSuccessfulSignIns > 0) threatScore += 3 - if (stats.foreignActivity > 0) threatScore += 3 - // An anonymous link exposes data to anyone holding the URL, past any later reset - if (stats.anonymousLinks > 0) threatScore += 3 - // Repeated-subject campaigns and send bursts are how a compromised mailbox spreads - if (stats.massMailFlagged) threatScore += 3 - // Persistence moves during the window: a fresh MFA method or device enrollment - if (stats.recentMfaDevices > 0) threatScore += 2 - if (stats.recentIntuneDevices > 0) threatScore += 2 - - if (threatScore >= 7) return { level: 'High', color: '#742A2A' } - if (threatScore >= 4) return { level: 'Medium', color: '#744210' } - return { level: 'Low', color: '#22543D' } - } - - const threatLevel = calculateThreatLevel() - - return ( - - } - > - {/* EXECUTIVE SUMMARY PAGE */} - - -
- - This report documents the findings of a Business Email Compromise (BEC) investigation - performed for the user account{' '} - {userData?.userPrincipalName} within{' '} - {tenantName}. The investigation analyzed - suspicious activity indicators including mailbox rules, permission changes, new - applications, authentication patterns, and sign-in locations over a 7-day period. - - - - Business Email Compromise is a sophisticated scam targeting organizations that regularly - perform wire transfers or have established relationships with foreign suppliers. - Attackers compromise legitimate email accounts through social engineering or computer - intrusion techniques to conduct unauthorized fund transfers, steal sensitive - information, or impersonate executives. - -
- -
- - - - - {threatLevel.level === 'High' && - 'HIGH RISK: Multiple indicators of compromise detected. Immediate remediation actions are strongly recommended. This account shows patterns consistent with active Business Email Compromise attacks.'} - {threatLevel.level === 'Medium' && - 'MEDIUM RISK: Suspicious activity patterns detected. Review findings and consider implementing recommended security measures. Some indicators suggest potential unauthorized access.'} - {threatLevel.level === 'Low' && - 'LOW RISK: Minimal suspicious activity detected. The findings show standard user behavior with no significant indicators of compromise. Continue monitoring as a precautionary measure.'} - -
- -
- {becData?.ExtractResult || 'Unknown'} - - Last 7 days ending {becData?.ExtractedAt ? formatDate(becData.ExtractedAt) : 'N/A'} - - - {locationAnalysis?.UsageLocation || - 'Not assigned - sign-ins and activity could not be compared against an expected country'} - -
-
- - {/* UNDERSTANDING BEC PAGE */} - - -
- - Business Email Compromise (BEC) is a type of cyberattack where criminals gain - unauthorized access to a business email account. Once inside, attackers can: - - - - Read sensitive - emails to learn about business operations, financial processes, and key - relationships. - - Send fraudulent - emails appearing to come from company leadership requesting wire transfers or - sensitive data. - - Intercept - legitimate invoices and alter payment information to redirect funds to - attacker-controlled accounts. - - Create email rules to - automatically delete or hide messages, preventing detection. - - -
- -
- - Attackers typically gain access to email accounts through: - - - - Deceptive emails that trick - users into providing their login credentials on fake websites. - - Automated attempts to - log in using common passwords across many accounts. - - Using usernames and - passwords leaked from other breached websites. - - Software that captures - keystrokes or steals stored passwords from compromised devices. - - -
- -
- - This analysis was initiated because suspicious activity was detected or reported for - this user account. The investigation examines multiple indicators that might suggest - account compromise, including unusual mailbox rules, unexpected permission changes, new - application authorizations, and abnormal sign-in patterns. Early detection is critical - to minimize potential damage and prevent financial loss or data theft. - -
-
- - {/* DETAILED FINDINGS PAGE */} - - - {/* Check 1: Mailbox Rules */} -
- - Attackers often create email rules to automatically forward, delete, or hide messages. - This prevents victims from seeing evidence of fraudulent activity. Suspicious rules - may move emails to obscure folders like "RSS Subscriptions" or forward them to - external addresses. - - - {stats.newRules > 0 && ( - <> - - The following mailbox rules were detected. Review each rule carefully to determine - if it was created by the user or by an attacker. Rules that forward emails or move - them to unusual folders are particularly suspicious. - - - {becData.NewRules.slice(0, 10).map((rule, index) => ( - - Description: {rule.Description || 'No description available'} - {'\n'} - {rule.MoveToFolder && `Moves to: ${rule.MoveToFolder}`} - {rule.ForwardTo && `\nForwards to: ${rule.ForwardTo}`} - {rule.DeleteMessage && '\nDeletes messages'} - {rule.RecentlyChanged && '\nCreated or changed in the last 7 days'} - - ))} - {becData.NewRules.length > 10 && ( - - ... and {becData.NewRules.length - 10} more rules (see JSON export for full list) - - )} - - )} - {stats.ruleChanges > 0 && ( - <> - - The audit log recorded inbox rules being created, changed or removed on this - mailbox. Rules that were removed after use are a common way for attackers to cover - their tracks. - - - {becData.InboxRuleChanges.slice(0, 10).map((change, index) => ( - - Date: {change.Date || 'Unknown'} - {'\n'} - By: {change.UserKey || 'Unknown'} - {change.ClientIP && - `\nFrom: ${change.ClientIP}${change.Country ? ` (${change.Country})` : ''}`} - {change.ForeignLocation === true && - '\n⚠️ Originated outside the assigned usage location'} - {change.Parameters && `\nParameters: ${change.Parameters}`} - - ))} - {becData.InboxRuleChanges.length > 10 && ( - - ... and {becData.InboxRuleChanges.length - 10} more changes (see JSON export for - full list) - - )} - - )} - {stats.newRules === 0 && stats.ruleChanges === 0 && ( - - No mailbox rules were detected that match suspicious patterns. This is a positive - indicator. - - )} -
-
- - {/* CHECK 2: NEW USERS */} - - -
- - Attackers sometimes create new user accounts to maintain persistent access or to use - as staging accounts for fraudulent activities. Reviewing recently created users helps - identify unauthorized account creation. - - - {stats.newUsers > 0 ? ( - <> - - The following users were created in the last 7 days. Verify that each account - creation was authorized and legitimate. - - - {becData.NewUsers.slice(0, 8).map((user, index) => ( - - Email: {user.userPrincipalName || 'N/A'} - {'\n'} - Created: {formatDate(user.createdDateTime)} - - ))} - {becData.NewUsers.length > 8 && ( - - ... and {becData.NewUsers.length - 8} more users (see JSON export for full list) - - )} - - ) : ( - - No new user accounts were created during the analysis period. - - )} -
- - {/* Check 3: New Applications */} -
- - Attackers may authorize malicious or suspicious third-party applications to access - your email and data. These applications can read emails, send messages, and access - files without the user's explicit knowledge. - - - {stats.maliciousApps > 0 && ( - - One or more applications in this tenant match the CIPP known-malicious application - catalog. Consent-based access survives a password reset, so these applications - should be removed unless their presence is explained. - - )} - - {stats.newApps > 0 ? ( - <> - - New applications were granted access during the analysis period. Review each - application to ensure it was authorized and is from a trusted publisher. - - - {becData.AddedApps.slice(0, 6).map((app, index) => ( - - Publisher: {app.publisher || 'Unknown'} - {'\n'} - App ID: {app.appId || 'N/A'} - {'\n'} - Created: {formatDate(app.createdDateTime)} - {app.MaliciousMatch && - `\n⚠️ Matches known-malicious catalog entry "${app.MaliciousMatch.Name}"${ - app.MaliciousMatch.Categories?.length - ? ` (${app.MaliciousMatch.Categories.join(', ')})` - : '' - }`} - - ))} - {becData.AddedApps.length > 6 && ( - - ... and {becData.AddedApps.length - 6} more apps (see JSON export for full list) - - )} - - ) : ( - (becData?.MaliciousSPs?.length || 0) === 0 && ( - - No new applications were authorized during the analysis period, and no known - malicious applications are present in the tenant. - - ) - )} - - {(becData?.MaliciousSPs?.length || 0) > 0 && ( - <> - {becData.MaliciousSPs.slice(0, 6).map((app, index) => ( - - Catalog entry: {app.CatalogName || 'Unknown'} - {'\n'} - App ID: {app.appId || 'N/A'} - {'\n'} - Categories: {app.Categories?.length ? app.Categories.join(', ') : 'N/A'} - {'\n'} - Enabled: {String(app.accountEnabled ?? 'Unknown')} - {'\n'} - First seen: {formatDate(app.createdDateTime)} - - ))} - {becData.MaliciousSPs.length > 6 && ( - - ... and {becData.MaliciousSPs.length - 6} more (see JSON export for full list) - - )} - - )} -
-
- - {/* CHECK 4, 5, 6, 7: PERMISSIONS, SENT MAIL, MFA, PASSWORDS */} - - - {/* Check 4: Mailbox Permission Changes */} -
- - Unauthorized changes to mailbox permissions can allow attackers to grant themselves or - accomplices access to read, send, or manage emails. This is a common technique to - maintain persistent access. - - - {stats.permissionChanges > 0 ? ( - <> - - Mailbox permission changes were detected. Verify that each change was authorized - and necessary for legitimate business purposes. - +import { useSettings } from '../hooks/use-settings' +import { ServerPdfPane, useServerPdf } from './CippPdf/useServerPdf' - {becData.MailboxPermissionChanges.slice(0, 5).map((change, index) => ( - - User: {change.UserKey || 'Unknown'} - {'\n'} - Target: {change.ObjectId || 'N/A'} - {'\n'} - Permissions: {change.Permissions || 'Unknown'} - {change.TargetsSuspect === true && - '\n⚠️ Targets the investigated mailbox'} - - ))} - {becData.MailboxPermissionChanges.length > 5 && ( - - ... and {becData.MailboxPermissionChanges.length - 5} more changes - - )} - - ) : ( - - No mailbox permission changes were detected during the analysis period. - - )} -
- - {/* Check 5: Sent Messages */} -
- - Attackers use a compromised mailbox to send fraudulent invoices, phishing, or - internal impersonation mail. The message trace shows what actually left the mailbox - during the analysis period, including the IP address it was sent from. - - - {stats.sentMessages > 0 ? ( - <> - - ℹ️ {stats.sentTotalMessages || stats.sentMessages} message(s) to{' '} - {stats.sentTotalRecipients || stats.sentMessages} recipient(s) were sent by this - mailbox during the analysis period - {stats.foreignSentMessages > 0 - ? `, including ${stats.foreignSentMessages} from an IP outside the user's assigned usage location.` - : '.'} - - - {stats.massMailFlagged && ( - - {stats.repeatedSubjects > 0 - ? `${stats.repeatedSubjects} subject(s) were sent as many separate messages or to many recipients. ` - : ''} - {stats.sendBursts > 0 - ? `${stats.sendBursts} short burst(s) of high-volume sending were detected. ` - : ''} - Identical-subject mass mail and send bursts are how a compromised mailbox - spreads phishing or fraudulent invoices. Review the campaigns below and warn - the recipients if the content was malicious. - - )} - - {(becData?.SentMessageAnalysis?.RepeatedSubjects || []) - .slice(0, 5) - .map((group, index) => ( - - Messages: {group.MessageCount} - {'\n'} - Recipients: {group.RecipientCount} - {'\n'} - First sent: {group.FirstSent || 'N/A'} - {'\n'} - Last sent: {group.LastSent || 'N/A'} - - ))} - {(becData?.SentMessageAnalysis?.RepeatedSubjects?.length || 0) > 5 && ( - - ... and {becData.SentMessageAnalysis.RepeatedSubjects.length - 5} more repeated - subjects (see JSON export for full list) - - )} - - {(becData?.SentMessageAnalysis?.Bursts || []).slice(0, 5).map((burst, index) => ( - - Starting: {burst.WindowStart || 'N/A'} - {burst.TopSubject && `\nMost common subject: ${burst.TopSubject}`} - - ))} - {(becData?.SentMessageAnalysis?.Bursts?.length || 0) > 5 && ( - - ... and {becData.SentMessageAnalysis.Bursts.length - 5} more bursts (see JSON - export for full list) - - )} - - {becData.SentMessages.slice(0, 10).map((msg, index) => ( - - To: {msg.RecipientAddress || 'N/A'} - {'\n'} - Status: {msg.Status || 'N/A'} - {'\n'} - Received: {msg.Received || 'N/A'} - {msg.FromIP && - `\nFrom IP: ${msg.FromIP}${msg.Country ? ` (${msg.Country})` : ''}`} - {msg.ForeignLocation === true && - '\n⚠️ Sent from outside the assigned usage location'} - - ))} - {becData.SentMessages.length > 10 && ( - - ... and {becData.SentMessages.length - 10} more messages (see JSON export for - full list) - - )} - - ) : ( - - No messages were sent by this mailbox during the analysis period. - - )} -
- - {/* Check 6: MFA Devices */} -
- - Multi-factor authentication (MFA) devices provide an additional layer of security. - Reviewing registered MFA methods helps identify if attackers have added unauthorized - devices to bypass security controls. - - - {stats.mfaDevices > 0 ? ( - <> - - ℹ️ {stats.mfaDevices} MFA device(s) registered - {stats.recentMfaDevices > 0 - ? `, including ${stats.recentMfaDevices} registered in the last 7 days. Verify the recent registrations were made by the user — attackers register their own method to keep access after a password reset.` - : '. Verify each device belongs to the user.'} - - - {[...becData.MFADevices] - .sort( - (a, b) => new Date(b?.createdDateTime || 0) - new Date(a?.createdDateTime || 0) - ) - .slice(0, 5) - .map((device, index) => ( - - Display Name: {device.displayName || 'N/A'} - {'\n'} - Registered: {formatDate(device.createdDateTime)} - {isRecentMfaDevice(device) && '\n⚠️ Registered in the last 7 days'} - - ))} - {becData.MFADevices.length > 5 && ( - - ... and {becData.MFADevices.length - 5} more methods (see JSON export for full - list) - - )} - - ) : ( - - No multi-factor authentication devices are registered. MFA is highly recommended to - prevent unauthorized access. - - )} -
- - {/* Check 7: Password Changes */} -
- - Attackers often change passwords to lock out legitimate users. Reviewing recent - password changes in the tenant helps identify if the compromised account's password - was changed or if other accounts were affected. - - - {stats.passwordChanges > 0 ? ( - <> - - ℹ️ {stats.passwordChanges} password change(s) detected in the tenant during the - analysis period. - - - {becData.ChangedPasswords.slice(0, 5).map((user, index) => ( - - Email: {user.userPrincipalName || 'N/A'} - {'\n'} - Last Password Change: {formatDate(user.lastPasswordChangeDateTime)} - - ))} - {becData.ChangedPasswords.length > 5 && ( - - ... and {becData.ChangedPasswords.length - 5} more (see JSON export for full - list) - - )} - - ) : ( - - ℹ️ No password changes detected during the analysis period. - - )} -
-
- - {/* CHECK 8, 9, 10: SENDER LISTS, DEVICES, LOCATIONS */} - - - {/* Check 8: Trusted & Blocked Senders */} -
- - Attackers may add their own domain to the Trusted Senders list so their fraudulent - messages bypass spam filtering, or add finance/security domains to the Blocked - Senders list so warnings and alerts are hidden from the victim in the Junk Email - folder. - - - {becData?.SafelistError && ( - - {becData.SafelistError} - {'\n'} - An empty list here does not mean the mailbox has no trusted or blocked senders. - - )} - - {stats.safelistChanges > 0 && ( - <> - - The audit log recorded changes to the Trusted/Blocked Senders and Domains list on - this mailbox. Review each change carefully. - - - {becData.SafelistChanges.slice(0, 10).map((change, index) => ( - - Date: {formatDate(change.Date)} - {change.ClientIP && - `\nFrom: ${change.ClientIP}${change.Country ? ` (${change.Country})` : ''}`} - {change.ForeignLocation === true && - '\n⚠️ Originated outside the assigned usage location'} - {'\n'} - Trusted: {formatSafelistValue(change.Trusted)} - {'\n'} - Blocked: {formatSafelistValue(change.Blocked)} - - ))} - {becData.SafelistChanges.length > 10 && ( - - ... and {becData.SafelistChanges.length - 10} more changes (see JSON export for - full list) - - )} - - )} - - {stats.trustedSenders > 0 && ( - {becData.TrustedSenders.slice(0, 15).join(', ')} - )} - {stats.trustedSenders > 15 && ( - - ... and {stats.trustedSenders - 15} more trusted entries (see JSON export for full - list) - - )} - - {stats.blockedSenders > 0 && ( - {becData.BlockedSenders.slice(0, 15).join(', ')} - )} - {stats.blockedSenders > 15 && ( - - ... and {stats.blockedSenders - 15} more blocked entries (see JSON export for full - list) - - )} - - {!becData?.SafelistError && - stats.trustedSenders === 0 && - stats.blockedSenders === 0 && - stats.safelistChanges === 0 && ( - - No trusted or blocked sender/domain entries were found on this mailbox. - - )} -
- - {/* Check 9: Intune Devices */} -
- - Newly enrolled Intune devices can indicate an attacker standing up a VM or BYOD - endpoint under the compromised identity, including paths that re-register Windows - Hello for Business. Review devices enrolled during the analysis window first. - - - {becData?.IntuneDevicesError ? ( - - {becData.IntuneDevicesError} - {'\n'} - An empty device list here does not mean the user has no Intune devices. - - ) : stats.intuneDevices > 0 ? ( - <> - - ℹ️ {stats.intuneDevices} Intune-managed device(s) associated with this user - {stats.recentIntuneDevices > 0 - ? `, including ${stats.recentIntuneDevices} enrolled in the last 7 days.` - : '. None were enrolled in the last 7 days.'} - - - {sortedIntuneDevices.slice(0, 5).map((device, index) => ( - - OS: {device.operatingSystem || 'N/A'} - {device.osVersion ? ` ${device.osVersion}` : ''} - {'\n'} - Enrolled: {formatDate(device.enrolledDateTime)} - {'\n'} - Compliance: {device.complianceState || 'N/A'} - {'\n'} - Enrollment Type: {device.deviceEnrollmentType || 'N/A'} - {device.serialNumber ? `\nSerial: ${device.serialNumber}` : ''} - - ))} - {sortedIntuneDevices.length > 5 && ( - - ... and {sortedIntuneDevices.length - 5} more devices (see JSON export for full - list) - - )} - - ) : ( - - No Intune-managed devices were found for this user. - - )} -
- - {/* Check 10: Sign-in Locations */} -
- - Sign-ins from countries the user does not work from are one of the strongest - compromise indicators. Each sign-in is compared against the user's assigned usage - location in Entra ID - {locationAnalysis?.UsageLocation ? ` (${locationAnalysis.UsageLocation})` : ''}, and - the client IPs behind rule changes, safelist changes, sharing changes, and sent mail - are geo-located and compared the same way. - - - {becData?.SuspectUserSignInsError ? ( - - {becData.SuspectUserSignInsError} - {'\n'} - An empty list here does not mean the user has not signed in. - - ) : ( - <> - {!locationAnalysis?.UsageLocation && ( - - {locationAnalysis?.Note || - 'The user has no usage location assigned in Entra ID, so activity cannot be compared against an expected country.'} - - )} - - {(locationAnalysis?.SignInCountries?.length || 0) > 0 && ( - - {locationAnalysis.SignInCountries.map( - (c) => `${c.Country}: ${c.Count} sign-in(s)` - ).join('\n')} - - )} - - {stats.foreignSignIns > 0 || stats.foreignActivity > 0 ? ( - <> - - {stats.foreignSignIns} sign-in(s) (of which {stats.foreignSuccessfulSignIns}{' '} - succeeded), {locationAnalysis?.ForeignRuleChangeCount || 0} inbox rule - change(s), {locationAnalysis?.ForeignSafelistChangeCount || 0} safelist - change(s), {locationAnalysis?.ForeignSharingChangeCount || 0} sharing - change(s), and {locationAnalysis?.ForeignSentMessageCount || 0} sent - message(s) originated outside {locationAnalysis?.UsageLocation}. Failed - foreign sign-ins are mostly password-spray noise; the successful ones prove - access. Review each carefully — a single legitimate trip can explain some of - this, but rule, safelist, or sharing changes from a foreign IP rarely have an - innocent explanation. - - - {foreignSignIns.slice(0, 10).map((signIn, index) => ( - - Application: {signIn.AppDisplayName || 'N/A'} - {'\n'} - IP Address: {signIn.IPAddress || 'N/A'} - {'\n'} - City: {signIn.City || 'N/A'} - {'\n'} - Result: {signIn.Status || 'N/A'} - - ))} - {foreignSignIns.length > 10 && ( - - ... and {foreignSignIns.length - 10} more foreign sign-ins (see JSON export - for full list) - - )} - - ) : locationAnalysis?.UsageLocation ? ( - - All located sign-ins and activity match the user's assigned usage location ( - {locationAnalysis.UsageLocation}). - - ) : null} - - )} -
- - {/* Check 11: Sharing Links */} -
- - Attackers share OneDrive and SharePoint folders to give themselves a data feed that - survives a password reset, and anonymous links expose the content to anyone holding - the URL. This check lists every sharing link the account created or changed during - the analysis period, including the IP address it was done from. - - - {stats.sharingChanges > 0 ? ( - <> - - {stats.anonymousLinks > 0 - ? `${stats.anonymousLinks} of these involve anonymous links, which anyone with the URL can open. ` - : ''} - Review each link and remove any that are not explained, even if the account has - since been remediated. - - - {becData.SharingChanges.slice(0, 10).map((change, index) => ( - - Date: {formatDate(change.Date)} - {'\n'} - Workload: {change.Workload || 'N/A'} - {change.Target && `\nShared with: ${change.Target}`} - {change.ClientIP && - `\nFrom: ${change.ClientIP}${change.Country ? ` (${change.Country})` : ''}`} - {change.ForeignLocation === true && - '\n⚠️ Originated outside the assigned usage location'} - - ))} - {becData.SharingChanges.length > 10 && ( - - ... and {becData.SharingChanges.length - 10} more changes (see JSON export for - full list) - - )} - - ) : ( - - No sharing links were created or changed by this account during the analysis - period. - - )} -
-
- - {/* RECOMMENDATIONS PAGE */} - - -
- - Based on the investigation findings, the following actions should be taken immediately: - - - - Change the user's - password immediately to prevent further unauthorized access. - - Sign out the user from - all active sessions to terminate any attacker access. - - Delete any - mailbox rules that forward, redirect, or hide emails, especially those moving - messages to unusual folders. - - Remove any MFA - devices that the user doesn't recognize and re-register legitimate devices. - - Review and revoke any - unauthorized mailbox permissions or application consents. - - Continue monitoring the - account for suspicious activity for at least 30 days. - - -
- -
- - To prevent future Business Email Compromise attacks, implement these security best - practices: - - - - {' '} - Require MFA for all users, especially those with administrative privileges or access - to financial systems. - - {' '} - Educate employees about phishing, social engineering, and how to identify suspicious - emails. Regular training significantly reduces successful attacks. - - Use - email security solutions that detect and block phishing, malware, and suspicious - attachments. - - {' '} - Restrict access based on location, device compliance, and risk level to prevent - unauthorized sign-ins. - - Regularly review - audit logs for suspicious activities such as unusual sign-in patterns, rule - creation, or permission changes. - - Implement - multi-person approval processes for wire transfers and payment changes to prevent - fraudulent transactions. - - -
- -
- - Share these key points with the affected user to help prevent future compromises: - - - - - Never click on links or open attachments in unexpected emails, even if they appear - to come from known contacts. - - - Always verify unusual requests for money transfers or sensitive information through - a separate communication channel (phone call, in person). - - - Use strong, unique passwords for each account and consider using a password manager. - - - Be cautious when authorizing new applications or granting permissions to third-party - services. - - - Report suspicious emails or activities to your IT security team immediately. - - -
-
- - {/* COMPLIANCE & DOCUMENTATION PAGE */} - - -
- - This report supports compliance and documentation requirements for various security - frameworks and regulatory standards: - - - - Demonstrates incident - detection, analysis, and response procedures (Controls A.16.1.1 - A.16.1.7). - - Provides evidence of - security incident monitoring, analysis, and documentation (AC.L2-3.1.12, - AU.L2-3.3.1). - - Documents detective and - responsive controls for security incidents (CC7.3, CC7.4). - - Aligns with Detect (DE.AE, - DE.CM) and Respond (RS.AN, RS.MI) functions. - - Demonstrates security breach - detection and potential data breach assessment (Articles 32, 33). - - -
- -
- - This investigation and resulting documentation provide an audit trail for security - incident response: - - - - Investigation Date: {formatDate(becData?.ExtractedAt)} - {'\n'} - Analyzed User: {userData?.userPrincipalName} - {'\n'} - Organization: {tenantName} - {'\n'} - Analysis Period: 7 days - {'\n'} - Assigned Usage Location: {locationAnalysis?.UsageLocation || 'Not assigned'} - {'\n'} - Audit Log Status: {becData?.ExtractResult || 'Unknown'} - - - - Threat Level: {threatLevel.level} - {'\n'} - Mailbox Rules Found: {stats.newRules} - {'\n'} - Rule Changes: {stats.ruleChanges} - {'\n'} - Permission Changes: {stats.permissionChanges} ({stats.permissionChangesTargetingUser}{' '} - targeting this mailbox) - {'\n'} - New Applications: {stats.newApps} - {'\n'} - Known-Malicious Applications: {stats.maliciousApps} - {'\n'} - New Users: {stats.newUsers} - {'\n'} - Sent Messages: {stats.sentTotalMessages || stats.sentMessages} - {'\n'} - Repeated Subject Campaigns: {stats.repeatedSubjects} - {'\n'} - Send Bursts: {stats.sendBursts} - {'\n'} - MFA Devices: {stats.mfaDevices} - {'\n'} - Recent MFA Registrations (7d): {stats.recentMfaDevices} - {'\n'} - Password Changes: {stats.passwordChanges} - {'\n'} - Trusted Senders: {stats.trustedSenders} - {'\n'} - Blocked Senders: {stats.blockedSenders} - {'\n'} - Safelist Changes: {stats.safelistChanges} - {'\n'} - Sharing Changes: {stats.sharingChanges} - {'\n'} - Anonymous Links: {stats.anonymousLinks} - {'\n'} - Intune Devices: {stats.intuneDevices} - {'\n'} - Recent Intune Enrollments (7d): {stats.recentIntuneDevices} - {'\n'} - Foreign Sign-ins: {stats.foreignSignIns} ({stats.foreignSuccessfulSignIns} successful) - {'\n'} - Foreign Rule/Safelist/Sharing/Mail Activity: {stats.foreignActivity} - -
- -
- - This report should be retained according to your organization's document retention - policy and regulatory requirements. Typical retention periods range from 3-7 years - depending on applicable compliance frameworks. Store this document securely with - restricted access as it contains sensitive security information. - -
- -
- - For more information about Business Email Compromise and cybersecurity best practices: - - - - - FBI IC3: Internet Crime Complaint Center (ic3.gov) - - - CISA: Cybersecurity & Infrastructure Security Agency (cisa.gov) - - - Microsoft Security: Business Email Compromise resources - - -
-
-
- ) -} - -// Main Button Component +// The report PDF is rendered server-side (ExecGetBecReportPdf) by the shared CIPPSharp component kit, +// which reads the stored BEC run (BecReports/BecResults) by case id; the button fetches the finished +// PDF as a blob for preview and download. A run is identified by its caseId - userId is the fallback +// the server uses to pick the user's newest completed run. export const BECRemediationReportButton = ({ userData, becData, tenantName }) => { const [dialogOpen, setDialogOpen] = useState(false) - const [isGenerating, setIsGenerating] = useState(false) + // 'full' = the complete report; 'summary' = the executive pages only, for a C-suite reader. + const [variant, setVariant] = useState('full') + const tenantFilter = useSettings().currentTenant - // Check if we have the necessary data + // Only offer the report once the BEC analysis has completed (its result is what the server reads). const hasData = userData && becData && !becData.Waiting - const brandingSettings = useBrandingSettings() - const variables = useReportVariables() - - const handleOpenDialog = () => { - setDialogOpen(true) - } - - const handleCloseDialog = () => { - setDialogOpen(false) - } + const params = new URLSearchParams({ + tenantFilter: tenantFilter ?? '', + caseId: becData?.CaseId ?? becData?.Run?.CaseId ?? '', + userId: userData?.id ?? userData?.userId ?? '', + userName: userData?.userPrincipalName ?? '', + userDisplayName: userData?.displayName ?? '', + variant, + }) + const pdf = useServerPdf({ url: `/api/ExecGetBecReportPdf?${params}`, enabled: dialogOpen }) + const handleOpenDialog = () => setDialogOpen(true) + const handleCloseDialog = () => setDialogOpen(false) + const fileName = `BEC_${variant === 'summary' ? 'Summary' : 'Report'}_${(userData?.userPrincipalName || 'user').replace(/[^a-zA-Z0-9]/g, '_')}_${new Date().toISOString().split('T')[0]}.pdf` if (!hasData) { return null // Don't show button if data isn't ready @@ -1268,54 +84,39 @@ export const BECRemediationReportButton = ({ userData, becData, tenantName }) => BEC Remediation Report Preview - - - + + value && setVariant(value)} + > + C-suite summary + Full report + + + + + - {hasData && ( - - - - )} + - - } - fileName={`BEC_Report_${userData?.userPrincipalName}_${new Date().toISOString().split('T')[0]}.pdf`} - style={{ textDecoration: 'none' }} + - )} - + Download PDF + diff --git a/src/components/CippAllTenants/AllTenantsDashboard.jsx b/src/components/CippAllTenants/AllTenantsDashboard.jsx index bcd68346eb22..755f034eb10d 100644 --- a/src/components/CippAllTenants/AllTenantsDashboard.jsx +++ b/src/components/CippAllTenants/AllTenantsDashboard.jsx @@ -50,6 +50,7 @@ export const AllTenantsDashboard = () => { const { tenantCount, tenants, + canReadLogs, alignmentApi, failedTestsApi, domainsApi, @@ -443,16 +444,18 @@ export const AllTenantsDashboard = () => { /> - - - + {canReadLogs && ( + + + + )} { { @@ -281,12 +282,17 @@ export const useAllTenantsDashboard = () => { waiting: true, }) + // ListLogs sits behind CIPP.Logs rather than the sign-in permission; a role without it skips the + // call so the dashboard does not surface a 403 for a page the role is allowed to open. + const { checkPermissions } = usePermissions() + const canReadLogs = checkPermissions(['CIPP.Logs.*']) + // ListLogs defaults to today's partition when no date is supplied, which keeps this cheap. const logsApi = ApiGetCall({ url: '/api/ListLogs', data: { Filter: 'True', Severity: 'Error,Critical' }, queryKey: 'AllTenantsDashboard-Logs', - waiting: true, + waiting: canReadLogs, }) // Score fields only — the endpoint projects away controlScores, which is ~15 KB per cached record. @@ -535,6 +541,7 @@ export const useAllTenantsDashboard = () => { return { tenantCount, tenants: tenantsApi, + canReadLogs, alignmentApi, failedTestsApi, domainsApi, diff --git a/src/components/CippBaselines/CippBaselineAddStandardDialog.jsx b/src/components/CippBaselines/CippBaselineAddStandardDialog.jsx new file mode 100644 index 000000000000..88bccfecde48 --- /dev/null +++ b/src/components/CippBaselines/CippBaselineAddStandardDialog.jsx @@ -0,0 +1,117 @@ +import { Stack, Typography } from '@mui/material' +import { useWatch } from 'react-hook-form' +import { ApiGetCall } from '../../api/ApiCall' +import { CippApiDialog } from '../CippComponents/CippApiDialog' +import CippFormComponent from '../CippComponents/CippFormComponent' +import CippFormSkeleton from '../CippFormPages/CippFormSkeleton' +import { CippBaselineStandardSettings } from './CippBaselineStandardSettings' + +const optionValue = (option) => + option && typeof option === 'object' ? option.value : option + +const AddStandardFields = ({ formHook, standard, baselines }) => { + const selectedBaseline = useWatch({ + control: formHook.control, + name: 'baselineId', + }) + const baseline = baselines.find( + (entry) => entry.GUID === optionValue(selectedBaseline) + ) + const stageOptions = (baseline?.stages ?? []).map((stage, index) => ({ + label: stage.name || `Stage ${index + 1}`, + value: index + 1, + })) + return ( + + + {standard.label} is added to the baseline you pick with the settings + below. The baseline applies it to its tenants on the next run. + + ({ + label: entry.templateName, + value: entry.GUID, + }))} + validators={{ required: { value: true, message: 'Pick a baseline' } }} + /> + {stageOptions.length > 1 && ( + + )} + + + + ) +} + +// Adds one standard to an existing baseline from anywhere in CIPP. The settings fields are the +// same CippBaselineStandardSettings the baseline editor renders, and the save goes through +// ExecBaselineAddStandard, which re-saves the baseline with New-CIPPBaseline. +export const CippBaselineAddStandardDialog = ({ + createDialog, + standardName, + relatedQueryKeys = [], +}) => { + const catalogApi = ApiGetCall({ + url: '/api/ListBaselineStandards', + queryKey: 'ListBaselineStandards', + }) + const baselinesApi = ApiGetCall({ + url: '/api/ListBaselines', + queryKey: 'ListBaselines', + }) + const catalog = Array.isArray(catalogApi.data) ? catalogApi.data : [] + const baselines = Array.isArray(baselinesApi.data) ? baselinesApi.data : [] + const standard = catalog.find((entry) => entry.name === standardName) + const label = standard?.label ?? standardName + + return ( + + standard ? ( + + ) : ( + + ) + } + api={{ + url: '/api/ExecBaselineAddStandard', + type: 'POST', + data: { standard: `!${standardName}` }, + confirmText: `Add ${label} to the selected baseline?`, + relatedQueryKeys: ['ListBaselines', ...relatedQueryKeys], + }} + row={{ standardName }} + /> + ) +} + +export default CippBaselineAddStandardDialog diff --git a/src/components/CippBaselines/CippBaselineWhatIfReport.jsx b/src/components/CippBaselines/CippBaselineWhatIfReport.jsx index 2f35fbd9427e..13a26cb5032a 100644 --- a/src/components/CippBaselines/CippBaselineWhatIfReport.jsx +++ b/src/components/CippBaselines/CippBaselineWhatIfReport.jsx @@ -1,26 +1,25 @@ -import { useState } from 'react' +import { useMemo, useState } from 'react' import { CippIcons } from '../../utils/icon-registry' import { - Autocomplete, Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, - TextField, + IconButton, + Paper, + Stack, + Switch, Tooltip, Typography, } from '@mui/material' -import { - Document, - Page, - StyleSheet, - Text, - View, -} from '@react-pdf/renderer' -import { CippPdfPreview } from '../CippPdf/CippPdfPreview' -import { parseCippDate } from '../../utils/parse-cipp-date' +import { CippAutoComplete } from '../CippComponents/CippAutocomplete' +import { CippOffCanvas } from '../CippComponents/CippOffCanvas' +import { ServerPdfPane, useServerPdf } from '../CippPdf/useServerPdf' +import { useBrandingSettings } from '../CippPdf/useBrandingSettings' +import { DEFAULT_BRANDING_OPTION } from '../ReportBuilder/reportSettings' +import { ApiGetCall } from '../../api/ApiCall' const operatorLabels = { eq: 'equals', @@ -29,7 +28,8 @@ const operatorLabels = { notStartsWith: 'does not start with', } -// Human-readable summary of a stage's graduation conditions. Shared with the alignment page. +// Human-readable summary of a stage's graduation conditions. Used by the alignment and +// template pages - deliberately NOT by the PDF, which tells the rollout in plain words. export const describeStageConditions = (stage) => { if (!stage?.conditions?.length) return 'no conditions configured' const parts = stage.conditions.map((condition) => { @@ -38,6 +38,8 @@ export const describeStageConditions = (stage) => { return `${condition.days} ${condition.unit ?? 'days'} in the previous stage` case 'variable': return `${condition.variable} ${operatorLabels[condition.operator] ?? condition.operator} '${condition.value}'` + case 'group': + return `the tenant is in the '${condition.groupName ?? condition.group}' group` case 'success': return 'all previous stage items applied successfully' case 'manual': @@ -49,352 +51,201 @@ export const describeStageConditions = (stage) => { return parts.join(stage.logic === 'or' ? ' OR ' : ' AND ') } -const accent = '#F77F00' - -const styles = StyleSheet.create({ - page: { - flexDirection: 'column', - backgroundColor: '#FFFFFF', - fontFamily: 'Helvetica', - fontSize: 10, - lineHeight: 1.5, - color: '#2D3748', - padding: 40, - }, - accentBar: { - height: 6, - backgroundColor: accent, - marginBottom: 16, - }, - title: { - fontSize: 22, - fontFamily: 'Helvetica-Bold', - marginBottom: 2, - }, - subtitle: { - fontSize: 11, - color: '#718096', - marginBottom: 18, - }, - sectionTitle: { - fontSize: 14, - fontFamily: 'Helvetica-Bold', - marginTop: 14, - marginBottom: 6, - color: '#1A202C', - }, - statRow: { - flexDirection: 'row', - gap: 8, - marginBottom: 4, - }, - statBox: { - flex: 1, - border: '1 solid #E2E8F0', - borderRadius: 6, - padding: 8, +// Report option toggles shown in the sidebar, in the executive report's card style. The keys +// are the endpoint's sectionConfig fields. +const sectionOptions = [ + { + key: 'alreadyAligned', + label: 'What Is Already In Place', + description: 'Deployed policies and enforced settings, with their values', }, - statValue: { - fontSize: 16, - fontFamily: 'Helvetica-Bold', - color: accent, + { + key: 'rolloutStages', + label: 'Rollout Stages', + description: 'How the remaining waves arrive and when', }, - statLabel: { - fontSize: 8, - color: '#718096', - textTransform: 'uppercase', - }, - item: { - marginBottom: 6, - paddingLeft: 8, - borderLeft: `2 solid ${accent}`, - }, - itemTitle: { - fontSize: 11, - fontFamily: 'Helvetica-Bold', - }, - itemText: { - fontSize: 9.5, - color: '#4A5568', - }, - meta: { - fontSize: 8.5, - color: '#718096', - }, - footer: { - position: 'absolute', - bottom: 24, - left: 40, - right: 40, - fontSize: 8, - color: '#A0AEC0', - textAlign: 'center', - }, -}) - -const WhatIfDocument = ({ - tenant, - stageStates, - simulatedTemplate, - catalogByName, -}) => { - const generatedAt = new Date().toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - }) - const changesNow = tenant.rows.filter((row) => - [ - 'Drift', - 'Partially Accepted', - 'Denied - Remediate Pending', - 'Denied - Delete Pending', - ].includes(row.status) - ) - const acceptedRows = tenant.rows.filter((row) => row.status === 'Accepted') - const plannedStages = stageStates.filter((state) => state.nextStage) +] - return ( - - - - Baseline What-If Report - - {tenant.displayName} ({tenant.tenantFilter}) - generated {generatedAt} - . No changes have been made; this report previews what applying the - configured standards would change. - - - Where you stand today - - - {tenant.alignedPercentage}% - - Compliant incl. accepted deviations - - - - {tenant.verifiedPercentage}% - Compliant with baseline - - - {changesNow.length} - Changes to make - - - {acceptedRows.length} - Agreed exceptions - - - - - Changes we would make now ({changesNow.length}) - - {changesNow.length === 0 && ( - - Nothing to change - every enforced standard is already in its - expected state. - - )} - {changesNow.map((row) => ( - - {row.standardLabel} - - {catalogByName[row.standardName]?.executiveText ?? - row.standardLabel} - - - {row.impact} - {row.secureScoreImpact > 0 - ? ` - increases Secure Score by up to ${row.secureScoreImpact} points` - : ''} - {row.status?.startsWith('Denied') - ? ' - deviation denied, fix pending' - : ''} - - - ))} - - - Planned future changes (staged rollout) - - {plannedStages.length === 0 && ( - - This tenant is in the final stage of every assigned baseline - no - further staged changes are planned. - - )} - {plannedStages.map((state) => { - const timeCondition = state.nextStage.conditions?.find( - (condition) => condition.type === 'time' - ) - const estimatedAt = timeCondition - ? new Date( - parseCippDate(state.enteredStageAt).getTime() + - timeCondition.days * - (timeCondition.unit === 'weeks' ? 7 : 1) * - 24 * - 60 * - 60 * - 1000 - ).toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - }) - : null - return ( - - {state.templateName} - - Currently in Stage {state.currentStage} of {state.totalStages} ( - {state.stageName}). Next: Stage {state.currentStage + 1} ( - {state.nextStageName}) - advances when{' '} - {describeStageConditions(state.nextStage)} - {estimatedAt ? `, estimated around ${estimatedAt}` : ''}. - - {state.nextStage.standards.map((standardName) => { - const standard = catalogByName[standardName] - if (!standard) return null - return ( - - {standard.label} - - {standard.executiveText} - - - ) - })} - - ) - })} - - {simulatedTemplate && ( - <> - - What-if: additionally assigning the{' '} - {simulatedTemplate.templateName} baseline - - - {simulatedTemplate.description}. This baseline is not assigned to - the tenant today - below is what assigning it would roll out, - stage by stage. - - {simulatedTemplate.stages.map((stage, index) => ( - - - Stage {index + 1}: {stage.name} - {index === 0 - ? ' - applies immediately' - : ` - advances when ${describeStageConditions(stage)}`} - - {[ - ...new Set(stage.standards.map((key) => key.split('#')[0])), - ].map((name) => { - const standard = catalogByName[name] - if (!standard) return null - const currentRow = tenant.rows.find( - (row) => row.standardName === name - ) - const alreadyAligned = currentRow?.status === 'Compliant' - return ( - - {standard.label} - - {standard.executiveText} - - - {alreadyAligned - ? `No change - already aligned today (configured by ${currentRow.sourceTemplate})` - : 'Would change this tenant when the stage applies'} - - - ) - })} - - ))} - - )} - - {acceptedRows.length > 0 && ( - <> - - Agreed exceptions we will not change ({acceptedRows.length}) - - {acceptedRows.map((row) => ( - - {row.standardLabel} - {row.deviationReason} - - ))} - - )} - - - Generated by CIPP Baselines - what-if preview, no changes were made. - - - - ) -} - -// Button + preview dialog in the style of the Executive Report button. +// Button + preview dialog in the executive report's shape: an options rail on the left +// (branding, simulated baselines, section toggles) and the PDF preview on the right. The PDF +// is rendered server-side (ExecGetBaselineWhatIfReportPdf), which reads the same alignment, +// baselines and stored templates this page shows, and re-renders whenever an option changes. export const CippBaselineWhatIfReport = ({ tenant, - stageStates, + stageStates = [], baselines = [], - catalog = [], }) => { const [open, setOpen] = useState(false) - const [simulatedTemplate, setSimulatedTemplate] = useState(null) + const [optionsOpen, setOptionsOpen] = useState(false) + const [simulatedSelection, setSimulatedSelection] = useState([]) + const [sectionConfig, setSectionConfig] = useState({ + alreadyAligned: true, + rolloutStages: true, + }) + // Null until the operator picks one, so the branding setting for this report type keeps + // applying as it changes. An explicit choice - including "Default" - wins from then on. + const [presetOverride, setPresetOverride] = useState(null) + const defaultBranding = useBrandingSettings() + const brandingPresetId = + presetOverride ?? defaultBranding?.reportDefaults?.baseline ?? '' - const catalogByName = Object.fromEntries( - catalog.map((standard) => [standard.name, standard]) + // Named branding sets a report can be rendered against. Only names/ids are needed now that + // the PDF is branded server-side, so this shares the executive report's image-free query. + const brandingPresets = ApiGetCall({ + url: '/api/ListBrandingPresets', + data: { includeImages: false }, + queryKey: 'ListBrandingPresets-list', + waiting: open, + }) + const presetOptions = useMemo( + () => [ + DEFAULT_BRANDING_OPTION, + ...(Array.isArray(brandingPresets.data) ? brandingPresets.data : []).map( + (preset) => ({ label: preset.name, value: preset.id }) + ), + ], + [brandingPresets.data] ) - // Baselines not currently rolled out to this tenant can be simulated in the report. + const tenantLabel = tenant?.displayName ?? tenant?.tenantFilter ?? 'tenant' + + // Baselines not rolled out to this tenant can be simulated in the report. const availableTemplates = baselines.filter( (template) => !stageStates.some((state) => state.templateId === template.GUID) ) - const reportDocument = ( - - ) + const pdf = useServerPdf({ + url: '/api/ExecGetBaselineWhatIfReportPdf', + body: { + tenantFilter: tenant?.tenantFilter, + simulatedTemplateIds: simulatedSelection.map((option) => option.value), + sectionConfig, + brandingPresetId, + }, + enabled: open, + }) - const handleDownload = () => { - import('@react-pdf/renderer').then(({ pdf }) => { - pdf(reportDocument) - .toBlob() - .then((blob) => { - const url = URL.createObjectURL(blob) - const link = document.createElement('a') - link.href = url - link.download = `WhatIf_Report_${tenant.displayName.replace(/[^a-zA-Z0-9]/g, '_')}.pdf` - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - URL.revokeObjectURL(url) - }) - }) - } + const fileName = `Baseline_Report_${tenantLabel.replace(/[^a-zA-Z0-9]/g, '_')}_${ + new Date().toISOString().split('T')[0] + }.pdf` + + const toggleSection = (key) => + setSectionConfig((prev) => ({ ...prev, [key]: !prev[key] })) + + // One definition, two homes: the desktop rail and the mobile drawer. The drawer's own + // header already says "Report Options", so it takes the panel without the heading. + const optionsPanel = ({ showHeading = true } = {}) => ( + + {showHeading && ( + + + Report Options + + )} + + Configure what the baseline report includes. Changes are reflected in + real-time. + + + + option.value === brandingPresetId) ?? + presetOptions[0] + } + onChange={(option) => setPresetOverride(option?.value ?? '')} + /> + + Presets are managed in Settings → Branding + + + + + ({ + label: template.templateName, + value: template.GUID, + }))} + value={simulatedSelection} + onChange={(options) => setSimulatedSelection(options ?? [])} + /> + + Everything these baselines would add appears in the report as planned + changes. + + + + + {sectionOptions.map((option) => ( + toggleSection(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() + toggleSection(option.key) + }} + onClick={(event) => event.stopPropagation()} + color="primary" + size="small" + /> + + + {option.label} + + + {option.description} + + + + ))} + + + ) return ( <> @@ -412,70 +263,97 @@ export const CippBaselineWhatIfReport = ({ onClose={() => setOpen(false)} maxWidth="xl" fullWidth + sx={{ + '& .MuiDialog-paper': { + height: { xs: '100dvh', md: '95vh' }, + maxHeight: { xs: '100dvh', md: '95vh' }, + }, + }} > - What-If Report - {tenant.displayName} - - - + Baseline Report - {tenantLabel} + + + {/* The options 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. */} + setOptionsOpen(true)} + size="small" + aria-label="Report options" + sx={{ display: { xs: 'inline-flex', md: 'none' } }} + > + + + setOpen(false)} size="small" - options={availableTemplates} - getOptionLabel={(option) => option.templateName} - value={simulatedTemplate} - onChange={(event, value) => setSimulatedTemplate(value)} - renderInput={(params) => ( - - )} - sx={{ maxWidth: 460 }} + aria-label="Close preview" + > + + + + + + {/* Left Panel - report options. Below md it lives in the drawer instead. */} + + {optionsPanel()} + + + {/* Right Panel - PDF preview */} + + - {open && ( - - {reportDocument} - - )} - - - - Exec-friendly preview - safe to send to customers. No changes are - made. - - + + - + + setOptionsOpen(false)} + title="Report Options" + size="sm" + contentPadding={0} + aboveModal + > + {optionsPanel({ showHeading: false })} + - ); + ) } export default CippBaselineWhatIfReport diff --git a/src/components/CippCards/CippBecRunStatusCard.jsx b/src/components/CippCards/CippBecRunStatusCard.jsx new file mode 100644 index 000000000000..4d37ca231c06 --- /dev/null +++ b/src/components/CippCards/CippBecRunStatusCard.jsx @@ -0,0 +1,218 @@ +import { + Alert, + Button, + Chip, + CircularProgress, + LinearProgress, + Stack, + SvgIcon, + Typography, +} from '@mui/material' +import { Box } from '@mui/system' +import { CippIcons } from '../../utils/icon-registry' +import ReactTimeAgo from 'react-time-ago' +import CippButtonCard from './CippButtonCard' +import { CippJobProgress } from '../CippComponents/CippJobProgress' + +const toDate = (value) => { + if (!value) return null + const date = new Date(value) + return Number.isNaN(date.getTime()) ? null : date +} + +/** + * The investigation's status card while there is no completed case on screen (a completed case + * renders the triage header instead). + * state: loading | none | waiting | error + * - none: the user has no run yet; nothing starts until the button is pressed + * - waiting: a run is queued (no worker has picked it up) or running (live steps from the + * async-deployment job, the same progress rows the SharePoint deploy uses) + * - error: the run failed; the failed phase is shown + * The header carries only the title and the state chips; the button lives in the footer so a + * long UPN never fights it for space. + */ +export const CippBecRunStatusCard = ({ + userPrincipalName, + state, + caseId, + poll, + onStart, + startPending = false, + windowDays = 7, +}) => { + const progress = poll?.Progress + const steps = Array.isArray(progress?.Steps) ? progress.Steps : [] + const doneCount = steps.filter((step) => step.Status === 'succeeded').length + const runningStep = steps.find((step) => step.Status === 'running') + const failedStep = steps.find((step) => step.Status === 'failed') + const jobQueued = + state === 'waiting' && (!progress || progress.Status === 'queued') + const requestedAt = toDate(poll?.RequestedAt) + const startedAt = toDate(poll?.StartedAt) + const busy = state === 'waiting' || state === 'loading' || startPending + + let statusChip = null + if (state === 'loading') { + statusChip = + } else if (state === 'none') { + statusChip = + } else if (state === 'waiting') { + statusChip = jobQueued ? ( + } + label="Queued - waiting for a worker" + /> + ) : ( + + ) + } else if (state === 'error') { + statusChip = + } + + const startButton = ( + + ) + + return ( + + + Business Email Compromise + + {userPrincipalName} + + + + {statusChip} + {caseId && ( + + )} + + + } + CardButton={startButton} + isFetching={false} + > + {state === 'loading' && ( + + + + Loading the user's runs... + + + )} + + {state === 'none' && ( + + + No investigation has been run for this user yet. Nothing is + collected until you start one; every run is kept as a case you can + return to, report on and export evidence from. + + + The investigation reads the last {windowDays} days of audit records, + sign-ins, permissions, rules, consents, devices and trace headers + across 21 checks. It collects metadata only - never message content + - and usually takes a few minutes. + + + )} + + {state === 'waiting' && ( + + {jobQueued ? ( + + The run is queued and waits for a background worker to pick it up + {requestedAt && ( + <> + {' '} + (requested ) + + )} + . Busy instances can hold it for a few minutes; the steps below + start moving as soon as a worker takes it. + + ) : ( + + {runningStep + ? `${runningStep.Title}: ${runningStep.Message || 'in progress'}` + : 'The worker has picked the run up.'} + {startedAt && ( + <> + {' '} + Started . + + )}{' '} + A run usually finishes within a few minutes; a tenant with a lot + of audit data can take up to ten. + + )} + 0 && !jobQueued ? 'determinate' : 'indeterminate' + } + value={ + steps.length > 0 + ? Math.round((doneCount / steps.length) * 100) + : 0 + } + /> + {progress ? ( + + ) : ( + + Waiting for the first status update... + + )} + + )} + + {state === 'error' && ( + + + {poll?.Error || 'The run failed.'} + {failedStep && ` Failed during: ${failedStep.Title}.`} + + {progress && } + + The failure is recorded in the logbook with the case id. Start a new + run once the cause is fixed; the failed run stays in the history. + + + )} + + ) +} + +export default CippBecRunStatusCard diff --git a/src/components/CippCards/CippBecTriageHeader.jsx b/src/components/CippCards/CippBecTriageHeader.jsx new file mode 100644 index 000000000000..4e71e3d9893f --- /dev/null +++ b/src/components/CippCards/CippBecTriageHeader.jsx @@ -0,0 +1,317 @@ +import { useMemo } from 'react' +import { Box, Stack, Grid } from '@mui/system' +import { Button, Chip, SvgIcon, Typography } from '@mui/material' +import { alpha } from '@mui/material/styles' +import { CippIcons } from '../../utils/icon-registry' +import CippButtonCard from './CippButtonCard' +import { ApiGetCall } from '../../api/ApiCall' +import { BECRemediationReportButton } from '../BECRemediationReportButton' +import { CippBecContainmentDrawer } from '../CippComponents/CippBecContainmentDrawer' +import { CippBecIPReviewDrawer } from '../CippComponents/CippBecIPReviewDrawer' +import { CippBecEvidenceExportButton } from '../CippComponents/CippBecEvidenceExportButton' +import { + becLevelColor, + becFindingFlags, + BEC_SIGNAL_GROUP, + becSkippedChecks, +} from '../../utils/bec-objectives' + +// The completed-case triage header: the verdict, why it fired, and what to do — before any +// evidence. The score's own Breakdown is the spine: each applied signal is a row you can click +// to land on the objective group that produced it. +export const CippBecTriageHeader = ({ + userData, + becData, + tenantFilter, + caseId, + onStartNew, + startPending = false, + onJumpToGroup, + // When the user has more than one run, the case page passes the case switcher here so it takes the + // header's title spot (in place of the static name/case); otherwise the name and case id show. + caseSelector, +}) => { + const score = becData?.Score + const applied = useMemo( + () => + [...(score?.Breakdown || [])] + .filter((s) => s.Applied) + .sort((a, b) => (b.Weight || 0) - (a.Weight || 0)), + [score] + ) + + // The drawer's own catalog (same query key, so one request): its DefaultSelected flags carry the + // instance-wide BEC Remediation Defaults, so this line always names what the drawer will pre-select. + const catalogCall = ApiGetCall({ + url: '/api/ListBECRemediationActions', + queryKey: 'ListBECRemediationActions', + }) + const defaultActions = useMemo(() => { + if (!Array.isArray(catalogCall.data)) return null + const labels = catalogCall.data + .filter((a) => a.DefaultSelected) + .sort((a, b) => (a.Order || 0) - (b.Order || 0)) + .map((a, i) => + i === 0 ? a.Label : a.Label.charAt(0).toLowerCase() + a.Label.slice(1) + ) + return labels.length + ? `${new Intl.ListFormat('en-GB').format(labels)} ${labels.length > 1 ? 'are' : 'is'} pre-selected in the drawer.` + : 'No containment actions are pre-selected in the drawer.' + }, [catalogCall.data]) + + // What the findings justify beyond the default containment steps — shown so the analyst + // knows the drawer will have targets waiting, not to replace the drawer's own selection. + const extras = useMemo(() => { + if (!becData) return [] + const flags = becFindingFlags(becData, becData.AnalysisWindowDays || 7) + return [ + ['NewRules', 'suspicious inbox rule'], + ['Delegations', 'flagged delegation'], + ['UserGrants', 'risky consent'], + ['TransportRuleChanges', 'risky transport-rule change'], + ['MailboxAddIns', 'flagged add-in'], + ['RegisteredDevices', 'new registered device'], + ] + .filter(([key]) => flags[key]) + .map(([key, label]) => ({ n: flags[key].count, label })) + }, [becData]) + + const upn = userData?.userPrincipalName + // becLevelColor can return 'default' (no score); keep a real palette color for the tint/text. + const lvl = becLevelColor(score?.Level) + const lvlColor = ['error', 'warning', 'success'].includes(lvl) + ? lvl + : 'primary' + // Checks that couldn't run (missing licence/permission) — surfaced so the score isn't read as a + // clean bill of health when evidence was simply unavailable. + const skipped = becData ? becSkippedChecks(becData) : [] + + return ( + + + {caseSelector ? ( + {caseSelector} + ) : ( + <> + + {userData?.displayName || upn} + + {caseId && ( + + )} + + )} + {score && ( + + )} + + + } + CardButton={ + // One row of same-size actions. The evidence-export button shows its result (and the ZIP SHA) + // in a popover anchored to itself rather than an inline panel, so it sits here beside the others + // without stretching the row. + + + {becData && ( + + )} + {becData && ( + + )} + {becData && ( + + )} + {becData && caseId && ( + + )} + + } + > + + + alpha(theme.palette[lvlColor].main, 0.12), + }} + > + + threat score + + + {score?.Value ?? '—'} + + + {score?.Level} + + + High ≥ {score?.Thresholds?.High} · Medium ≥{' '} + {score?.Thresholds?.Medium} + + + + + + + Why — {applied.length} of {score?.Breakdown?.length || 0} signals + fired. A score is a prompt to look, not a verdict; click a signal + for its evidence. + + + {applied.length === 0 && ( + + No scoring signals fired. Review the evidence below to confirm. + + )} + {applied.map((s) => ( + onJumpToGroup?.(BEC_SIGNAL_GROUP[s.Signal])} + sx={{ + cursor: BEC_SIGNAL_GROUP[s.Signal] ? 'pointer' : 'default', + borderRadius: 1, + px: 0.5, + '&:hover': { bgcolor: 'action.hover' }, + }} + > + = 4 ? 'error' : 'warning'} + label={`+${s.Weight}`} + sx={{ minWidth: 44 }} + /> + + {s.Description} + + + {s.Count} + + + ))} + + {skipped.length > 0 && ( + + {skipped.length} check{skipped.length > 1 ? 's' : ''} could not + run (missing a licence, permission, mailbox or service) — the + score may be understated. + + )} + + + + + Recommended containment + + {defaultActions && ( + + {defaultActions} + + )} + {extras.length > 0 && ( + + {extras.map((x) => ( + + + + + + {x.n} {x.label} + {x.n > 1 ? 's' : ''} + + + ))} + + )} + + + + ) +} + +export default CippBecTriageHeader diff --git a/src/components/CippCards/CippUniversalSearchV2.jsx b/src/components/CippCards/CippUniversalSearchV2.jsx index a017782e50f8..d37708ac15c9 100644 --- a/src/components/CippCards/CippUniversalSearchV2.jsx +++ b/src/components/CippCards/CippUniversalSearchV2.jsx @@ -29,8 +29,15 @@ import { nativeMenuItems } from "../../layouts/config"; import { usePermissions } from "../../hooks/use-permissions"; import { useIsMobileLayout } from "../../hooks/use-breakpoint"; import { useUserBookmarks } from "../../hooks/use-user-bookmarks"; +import { useSettings } from "../../hooks/use-settings"; +import { useTenantPreferences } from "../../hooks/use-tenant-preferences"; import { searchLocalLicenseCatalog } from "../../utils/get-cipp-license-catalog"; +// Scopes that match in memory as the user types; the rest hit the API on Enter / Search. +const LOCAL_SCOPES = ["Pages", "Licenses", "Tenants"]; +// Group-driven tenant searches can match hundreds of tenants — cap what is rendered. +const MAX_TENANT_RESULTS = 50; + function getLeafItems(items = []) { let result = []; @@ -45,47 +52,31 @@ function getLeafItems(items = []) { return result; } -async function loadTabOptions() { - const tabOptionPaths = [ - "/email/administration/exchange-retention", - "/cipp/custom-data", - "/cipp/advanced/super-admin", - "/cipp/advanced/container-management", - "/cipp/advanced/authentication", - "/endpoint/MEM/enrollment-profiles", - "/tenant/standards", - "/tenant/manage", - "/tenant/administration/applications", - "/tenant/administration/tenants", - "/tenant/administration/audit-logs", - "/identity/administration/users/user", - "/tenant/administration/securescore", - "/tenant/gdap-management", - "/tenant/gdap-management/relationships/relationship", - "/cipp/settings", - ]; - - const tabOptions = []; - - for (const basePath of tabOptionPaths) { - try { - const module = await import(`../../pages${basePath}/tabOptions.json`); - const options = module.default || module; - - options.forEach((option) => { - tabOptions.push({ - title: option.label, - path: option.path, - type: "tab", - basePath, - }); - }); - } catch (error) { - console.debug(`Could not load tabOptions for ${basePath}:`, error); - } - } - - return tabOptions; +/** + * Load every tabOptions.json under pages/ at build time. Globbed rather than listed so a new + * tabbed page is searchable without anyone remembering to register it here. The previous + * hardcoded list had drifted to cover only half of the tabbed pages (issue #668). + */ +function loadTabOptions() { + const context = require.context("../../pages", true, /tabOptions\.json$/); + + return context.keys().flatMap((key) => { + const tabModule = context(key); + const options = tabModule.default || tabModule; + if (!Array.isArray(options)) return []; + + // './tenant/manage/tabOptions.json' -> '/tenant/manage' + const basePath = key.replace(/^\./, "").replace(/\/tabOptions\.json$/, ""); + + return options + .filter((option) => option?.label && option?.path) + .map((option) => ({ + title: option.label, + path: option.path, + type: "tab", + basePath, + })); + }); } function filterItemsByPermissionsAndRoles(items, userPermissions, userRoles) { @@ -134,7 +125,7 @@ export const CippUniversalSearchV2 = React.forwardRef( const [searchValue, setSearchValue] = useState(value); const [searchType, setSearchType] = useState(defaultSearchType); const [bitlockerLookupType, setBitlockerLookupType] = useState("keyId"); - const [tabOptions, setTabOptions] = useState([]); + const [tabOptions] = useState(loadTabOptions); const [showDropdown, setShowDropdown] = useState(false); const [highlightedIndex, setHighlightedIndex] = useState(-1); const [bitlockerDrawerVisible, setBitlockerDrawerVisible] = useState(false); @@ -151,6 +142,25 @@ export const CippUniversalSearchV2 = React.forwardRef( const { userPermissions, userRoles } = usePermissions(); const isMobile = useIsMobileLayout(); const { bookmarks } = useUserBookmarks(); + const settings = useSettings(); + const { trackRecent } = useTenantPreferences(); + + // Same url/data/queryKey as the top-nav tenant selector and the form group selector, so + // both are served from the cache those already filled rather than fetched again. + const tenantList = ApiGetCall({ + url: "/api/listTenants", + data: { AllTenantSelector: true }, + queryKey: "TenantSelector", + waiting: searchType === "Tenants", + refetchOnMount: false, + refetchOnReconnect: false, + }); + const tenantGroupList = ApiGetCall({ + url: "/api/ListTenantGroups", + data: { AllTenantSelector: true }, + queryKey: "TenantGroupSelector", + waiting: searchType === "Tenants", + }); const universalSearch = ApiGetCall({ url: `/api/ExecUniversalSearchV2`, @@ -285,6 +295,74 @@ export const CippUniversalSearchV2 = React.forwardRef( return normalizedSearch ? inTitle || inPath || inBreadcrumbs || inScope : false; }); + // Tenants match on their own fields or on the name of any group they belong to, so + // "All Tenants (Excluding Partner)" lists every member and "cyberdrain04" shows which + // groups that one tenant is in. Direct hits sort ahead of group-only hits. + const tenantResults = useMemo(() => { + if (searchType !== "Tenants" || !normalizedSearch) return []; + const tenants = Array.isArray(tenantList.data) ? tenantList.data : []; + const groups = Array.isArray(tenantGroupList.data?.Results) + ? tenantGroupList.data.Results + : []; + + const groupsByCustomerId = new Map(); + for (const group of groups) { + for (const member of group.Members ?? []) { + if (!member?.customerId) continue; + if (!groupsByCustomerId.has(member.customerId)) { + groupsByCustomerId.set(member.customerId, []); + } + groupsByCustomerId.get(member.customerId).push({ + Id: group.Id, + Name: group.Name, + GroupType: group.GroupType, + }); + } + } + + const matches = []; + for (const tenant of tenants) { + const tenantGroups = groupsByCustomerId.get(tenant.customerId) ?? []; + const ownFields = [ + tenant.displayName, + tenant.defaultDomainName, + tenant.initialDomainName, + tenant.customerId, + ]; + const directHit = ownFields.some((field) => + field?.toLowerCase().includes(normalizedSearch), + ); + const matchedGroups = tenantGroups.filter((group) => + group.Name?.toLowerCase().includes(normalizedSearch), + ); + if (!directHit && matchedGroups.length === 0) continue; + matches.push({ + Type: "Tenant", + displayName: tenant.displayName, + defaultDomainName: tenant.defaultDomainName, + initialDomainName: tenant.initialDomainName, + customerId: tenant.customerId, + groups: tenantGroups, + matchedGroupIds: matchedGroups.map((group) => group.Id), + directHit, + }); + } + + matches.sort((a, b) => { + if (a.directHit !== b.directHit) return a.directHit ? -1 : 1; + return (a.displayName ?? "").localeCompare(b.displayName ?? ""); + }); + return matches; + }, [searchType, normalizedSearch, tenantList.data, tenantGroupList.data]); + const tenantResultsTruncated = tenantResults.length > MAX_TENANT_RESULTS; + const visibleTenantResults = tenantResultsTruncated + ? tenantResults.slice(0, MAX_TENANT_RESULTS) + : tenantResults; + const tenantsLoading = + searchType === "Tenants" && + ((tenantList.isFetching && !tenantList.data) || + (tenantGroupList.isFetching && !tenantGroupList.data)); + const handleChange = (event) => { const newValue = event.target.value; setSearchValue(newValue); @@ -292,12 +370,9 @@ export const CippUniversalSearchV2 = React.forwardRef( if (newValue.length === 0) { setShowDropdown(false); - } else if (searchType === "Pages") { - updateDropdownPosition(); - setShowDropdown(true); - } else if (searchType === "Licenses") { - // Local catalog is in-memory, so reveal results as the user types. - // The API fallback still requires the Search button (handleSearch). + } else if (LOCAL_SCOPES.includes(searchType)) { + // These scopes match in memory, so reveal results as the user types. The Licenses + // API fallback still requires the Search button (handleSearch). updateDropdownPosition(); setShowDropdown(true); } @@ -320,7 +395,29 @@ export const CippUniversalSearchV2 = React.forwardRef( } }; + // Tab cycles the scope (Users -> Groups -> ... -> Pages) while the field has focus, the + // way the omnibox and command palettes do. The typed text is kept so "john" can be + // re-scoped from Users to Groups without retyping; Escape still leaves the dialog. + const cycleSearchType = (direction) => { + const labels = typeMenuActions.map((action) => action.label); + const currentIndex = Math.max(0, labels.indexOf(searchType)); + const nextType = labels[(currentIndex + direction + labels.length) % labels.length]; + handleTypeChange(nextType); + const hasValue = searchValue.trim().length > 0; + if (hasValue && LOCAL_SCOPES.includes(nextType)) { + // These scopes match locally as you type, so the results can reappear straight away + updateDropdownPosition(); + setShowDropdown(true); + } + }; + const handleKeyDown = (event) => { + if (event.key === "Tab") { + event.preventDefault(); + cycleSearchType(event.shiftKey ? -1 : 1); + return; + } + if (event.key === "Escape" && showDropdown) { event.preventDefault(); setShowDropdown(false); @@ -341,9 +438,13 @@ export const CippUniversalSearchV2 = React.forwardRef( return; } - if (event.key === "Enter" && showDropdown && hasResults && highlightedIndex >= 0) { + // Enter opens the highlighted row, or the only row when the search produced exactly one + // hit, so a single result never needs an arrow-down before it can be selected. + const enterTargetIndex = + highlightedIndex >= 0 ? highlightedIndex : activeResults.length === 1 ? 0 : -1; + if (event.key === "Enter" && showDropdown && hasResults && enterTargetIndex >= 0) { event.preventDefault(); - const selectedItem = activeResults[highlightedIndex]; + const selectedItem = activeResults[enterTargetIndex]; if (!selectedItem) { return; } @@ -368,7 +469,7 @@ export const CippUniversalSearchV2 = React.forwardRef( if (localLicenseResults.length === 0) { activeSearch?.refetch(); } - } else if (searchType !== "Pages") { + } else if (!LOCAL_SCOPES.includes(searchType)) { activeSearch?.refetch(); } setShowDropdown(true); @@ -398,6 +499,29 @@ export const CippUniversalSearchV2 = React.forwardRef( } } else if (searchType === "Pages") { router.push(match.path, undefined, { shallow: true }); + } else if (searchType === "Tenants") { + // The URL is the tenant selector's source of truth: its watcher picks the new + // tenantFilter up and syncs its own state. Settings are updated here too so the + // switch sticks when the selector is not mounted (mobile drawer closed). + const domain = match.defaultDomainName; + router.replace( + { pathname: router.pathname, query: { ...router.query, tenantFilter: domain } }, + undefined, + { shallow: true }, + ); + settings?.handleUpdate?.({ currentTenant: domain }); + if (domain !== "AllTenants") { + trackRecent({ + value: domain, + label: `${match.displayName} (${domain})`, + addedFields: { + defaultDomainName: domain, + displayName: match.displayName, + customerId: match.customerId, + initialDomainName: match.initialDomainName, + }, + }); + } } else if (searchType === "Licenses") { if (typeof onLicenseSelect === "function") { onLicenseSelect(itemData); @@ -431,6 +555,11 @@ export const CippUniversalSearchV2 = React.forwardRef( }; const typeMenuActions = [ + { + label: "Tenants", + icon: "Business", + onClick: () => handleTypeChange("Tenants"), + }, { label: "Users", icon: "Groups", @@ -533,10 +662,6 @@ export const CippUniversalSearchV2 = React.forwardRef( } }, [highlightedIndex, showDropdown]); - useEffect(() => { - loadTabOptions().then(setTabOptions); - }, []); - useEffect(() => { setSearchType(defaultSearchType); if (defaultSearchType === "BitLocker") { @@ -559,17 +684,12 @@ export const CippUniversalSearchV2 = React.forwardRef( ? bitlockerResults : searchType === "Pages" ? pageResults - : searchType === "Licenses" - ? licenseResults - : universalResults; - const hasResults = - searchType === "BitLocker" - ? bitlockerResults.length > 0 - : searchType === "Pages" - ? pageResults.length > 0 - : searchType === "Licenses" - ? licenseResults.length > 0 - : universalResults.length > 0; + : searchType === "Tenants" + ? visibleTenantResults + : searchType === "Licenses" + ? licenseResults + : universalResults; + const hasResults = activeResults.length > 0; const shouldShowDropdown = showDropdown && searchValue.length > 0; const getLabel = () => { @@ -583,6 +703,8 @@ export const CippUniversalSearchV2 = React.forwardRef( : "Search BitLocker by Recovery Key ID"; } else if (searchType === "Pages") { return "Search pages, tabs, paths, or scope"; + } else if (searchType === "Tenants") { + return "Search tenants by name, domain, ID, or tenant group"; } else if (searchType === "Licenses") { return "Search licenses by SKU ID, part number, name, or service plan"; } @@ -593,7 +715,7 @@ export const CippUniversalSearchV2 = React.forwardRef( // floating panel; the phone dialog IS the surface, so it renders in flow. const resultsBody = ( <> - {activeSearch?.isFetching ? ( + {activeSearch?.isFetching || tenantsLoading ? ( @@ -609,6 +731,16 @@ export const CippUniversalSearchV2 = React.forwardRef( highlightedIndex={highlightedIndex} setHighlightedIndex={setHighlightedIndex} /> + ) : searchType === "Tenants" ? ( + ) : searchType === "Pages" ? ( - {searchType !== "Pages" && ( + {searchType !== "Pages" && searchType !== "Tenants" && ( + + + + } sx={{ pb: 1 }} /> @@ -303,16 +336,16 @@ export const AlertsOverviewCard = ({ tenantFilter, sx }) => { /> { + const entry = auditLogTemplates.find((template) => template.value === preset) + if (entry?.template) return { name: entry.name, ...entry.template } + return { + name: `${operation} is recorded in the audit log`, + preset: null, + logbook: { value: logbook, label: logbookLabels[logbook] ?? logbook }, + conditions: [ + { + Property: { value: 'List:Operation', label: 'Operation' }, + Operator: { value: 'EQ', label: 'Equals to' }, + Input: { value: operation, label: operation }, + }, + ], + } +} + +// Creates an audit-log alert for one tenant through the same AddAlert call the alert +// configuration page makes, with the same action options. Anything beyond the preset's own +// conditions is edited on that page afterwards. +export const CippAlertPresetDialog = ({ + createDialog, + tenant, + preset, + operation, + logbook, + relatedQueryKeys = [], +}) => { + const template = alertTemplateFor({ preset, operation, logbook }) + + return ( + ( + + + Creates the alert "{template.name}" for {tenant}. Once saved it + appears in the alert configuration list like any other alert, where + its conditions and actions can be changed. + + + + + )} + api={{ + url: '/api/AddAlert', + type: 'POST', + confirmText: `Enable this alert for ${tenant}?`, + relatedQueryKeys: ['ListAlertsQueue', ...relatedQueryKeys], + customDataformatter: (row, action, formData) => ({ + tenantFilter: [{ value: tenant, label: tenant, type: 'Tenant' }], + excludedTenants: [], + preset: template.preset, + logbook: template.logbook, + conditions: template.conditions, + Actions: formData.Actions, + AlertComment: formData.AlertComment ?? '', + CustomSubject: '', + }), + }} + row={{ preset, operation, logbook }} + /> + ) +} + +export default CippAlertPresetDialog diff --git a/src/components/CippComponents/CippAlertSnoozeDialog.jsx b/src/components/CippComponents/CippAlertSnoozeDialog.jsx index b89bb2b5fbec..0885eaad0efc 100644 --- a/src/components/CippComponents/CippAlertSnoozeDialog.jsx +++ b/src/components/CippComponents/CippAlertSnoozeDialog.jsx @@ -8,6 +8,7 @@ import { RadioGroup, FormControlLabel, Radio, + Checkbox, TextField, Typography, Box, @@ -21,10 +22,18 @@ import { humanizeCmdlet, } from '../../utils/format-alert-item' +export const UNTIL_RESOLVED = 'until-resolved' + const SNOOZE_OPTIONS = [ { value: '7', label: 'Snooze for 7 days' }, { value: '14', label: 'Snooze for 14 days' }, { value: '30', label: 'Snooze for 30 days' }, + { value: '90', label: 'Snooze for 90 days' }, + { + value: UNTIL_RESOLVED, + label: 'Snooze until it resolves', + hint: 'Lifts itself once the alert stops reporting this item, so it notifies again if it ever comes back.', + }, ] export const CippAlertSnoozeDialog = ({ @@ -36,6 +45,7 @@ export const CippAlertSnoozeDialog = ({ relatedQueryKeys, }) => { const [duration, setDuration] = useState('7') + const [keepVisible, setKeepVisible] = useState(false) const [reason, setReason] = useState('') const [submitted, setSubmitted] = useState(false) @@ -45,13 +55,16 @@ export const CippAlertSnoozeDialog = ({ const handleSnooze = () => { setSubmitted(true) + const untilResolved = duration === UNTIL_RESOLVED snoozeRequest.mutate({ url: '/api/ExecSnoozeAlert', data: { CmdletName: cmdletName, TenantFilter: tenantFilter, AlertItem: alertItem, - Duration: parseInt(duration, 10), + Duration: untilResolved ? null : parseInt(duration, 10), + UntilResolved: untilResolved, + KeepVisible: keepVisible, Reason: reason, }, }) @@ -61,6 +74,7 @@ export const CippAlertSnoozeDialog = ({ setSubmitted(false) snoozeRequest.reset() setDuration('7') + setKeepVisible(false) setReason('') onClose() } @@ -86,9 +100,7 @@ export const CippAlertSnoozeDialog = ({ bgcolor: 'action.hover', }} > - + {alertLabel} {fields.length > 0 ? ( @@ -98,20 +110,27 @@ export const CippAlertSnoozeDialog = ({ + flexShrink: 0, + }} + > {field.label} - + {field.value} ))} ) : ( - + {title} )} @@ -119,25 +138,61 @@ export const CippAlertSnoozeDialog = ({ )} {!submitted ? ( - - Choose how long to snooze this specific alert item. It will not trigger notifications - until the snooze expires. + + Choose how long to snooze this specific alert item. It will not + trigger notifications until the snooze ends. CIPP keeps checking + it either way. - setDuration(e.target.value)}> + setDuration(e.target.value)} + > {SNOOZE_OPTIONS.map((option) => ( } - label={option.label} + label={ + option.hint ? ( + + {option.label} + + {option.hint} + + + ) : ( + option.label + ) + } /> ))} + setKeepVisible(e.target.checked)} + /> + } + label={ + + + Keep it visible on the dashboard + + + Stays in the open list marked as snoozed, so it is not + forgotten. Unticked, it moves to the snoozed section. + + + } + /> - ); + ) } diff --git a/src/components/CippComponents/CippApiLogsDrawer.jsx b/src/components/CippComponents/CippApiLogsDrawer.jsx index 3e7d254e2ff1..65916f798e73 100644 --- a/src/components/CippComponents/CippApiLogsDrawer.jsx +++ b/src/components/CippComponents/CippApiLogsDrawer.jsx @@ -3,6 +3,7 @@ import { CippIcons } from '../../utils/icon-registry' import { Button, Box } from '@mui/material' import { CippOffCanvas } from './CippOffCanvas' import { CippDataTable } from '../CippTable/CippDataTable' +import { usePermissions } from '../../hooks/use-permissions' export const CippApiLogsDrawer = ({ buttonText = 'View API Logs', @@ -17,6 +18,10 @@ export const CippApiLogsDrawer = ({ ...props }) => { const [drawerVisible, setDrawerVisible] = useState(false) + const { checkPermissions } = usePermissions() + // ListLogs sits behind CIPP.Logs rather than the sign-in permission, so a role without it + // gets no button instead of a 403 inside the drawer. + const canReadLogs = checkPermissions(['CIPP.Logs.*']) const handleCloseDrawer = () => { setDrawerVisible(false) @@ -60,6 +65,8 @@ export const CippApiLogsDrawer = ({ }, ] + if (!canReadLogs) return null + return ( <> { const tableDialog = useDialog() // Optional live job progress: when the mutation result carries jobProgress.idField, poll - // jobProgress.url(id) until every row reaches a terminal state. + // jobProgress.url(id) until every row reaches a terminal state, then call jobProgress.onComplete(rows). + // Pass a memoized jobProgress: a new object each render re-arms the poll. const jobIdField = jobProgress?.idField ?? 'JobId' useEffect(() => { if (!jobProgress) return @@ -160,7 +161,10 @@ export const CippApiResults = (props) => { refetchInterval: jobPollActive ? (jobProgress?.interval ?? 5000) : false, staleTime: 0, }) - const jobRows = Array.isArray(jobStatus.data) ? jobStatus.data : [] + const jobRows = useMemo( + () => (Array.isArray(jobStatus.data) ? jobStatus.data : []), + [jobStatus.data] + ) // After a re-run the finished rows stay as they are until the job rewrites them, so keep polling // until a row goes active again, or give up after 90 s if the re-run never started. const restartedAt = useRef(null) @@ -176,7 +180,8 @@ export const CippApiResults = (props) => { } if (restartedAt.current && Date.now() - restartedAt.current < 90000) return setJobPollActive(false) - }, [jobPollActive, jobRows, jobStatus.dataUpdatedAt]) + jobProgress?.onComplete?.(jobRows) + }, [jobPollActive, jobRows, jobStatus.dataUpdatedAt, jobProgress]) const pageTitle = `${document.title} - Results` const correctResultObj = useMemo(() => { if (!apiObject.isSuccess) return diff --git a/src/components/CippComponents/CippApplicationDeployDrawer.jsx b/src/components/CippComponents/CippApplicationDeployDrawer.jsx index d125c275a7dd..52f7bac4a5ee 100644 --- a/src/components/CippComponents/CippApplicationDeployDrawer.jsx +++ b/src/components/CippComponents/CippApplicationDeployDrawer.jsx @@ -6,6 +6,11 @@ import { useForm, useWatch } from 'react-hook-form' import { CippOffCanvas } from './CippOffCanvas' import CippFormComponent from './CippFormComponent' import { CippFormTenantSelector } from './CippFormTenantSelector' +import { + applyPickedGroups, + getGroupPickerField, + getSingleDeployTenant, +} from './CippIntunePolicyActions' import { CippFormCondition } from './CippFormCondition' import { CippApiResults } from './CippApiResults' import languageList from '../../data/languageList.json' @@ -25,6 +30,18 @@ export const CippApplicationDeployDrawer = ({ control: formControl.control, name: 'selectedTenants', }) + // With exactly one tenant selected, groups are picked by id from that tenant. Otherwise the + // name fields stay, since only names can span tenants. + const groupTenant = getSingleDeployTenant(selectedTenants) + const singleTenant = Boolean(groupTenant) + // A group selection is only valid for the tenant it was loaded from, and a stale name from + // the other mode must not ship either. + useEffect(() => { + formControl.setValue('groupTargets', []) + formControl.setValue('excludeGroupTargets', []) + formControl.setValue('customGroup', '') + formControl.setValue('excludeGroup', '') + }, [groupTenant, formControl]) const applicationType = useWatch({ control: formControl.control, @@ -97,7 +114,7 @@ export const CippApplicationDeployDrawer = ({ const handleSubmit = () => { const formData = formControl.getValues() - const formattedData = { ...formData } + const formattedData = singleTenant ? applyPickedGroups(formData) : { ...formData } formattedData.tenantFilter = 'allTenants' //added to prevent issues with location check. temp fix formattedData.selectedTenants = selectedTenants.map((tenant) => ({ defaultDomainName: tenant.value, @@ -376,54 +393,6 @@ export const CippApplicationDeployDrawer = ({ ))} - - {/* Assign To Options */} - - - - - - - - - - - - - {/* WinGet App Section */} @@ -514,54 +483,6 @@ export const CippApplicationDeployDrawer = ({ formControl={formControl} /> - - {/* Assign To Options */} - - - - - - - - - - - - - {/* Chocolatey App Section */} @@ -675,54 +596,6 @@ export const CippApplicationDeployDrawer = ({ formControl={formControl} /> - - {/* Assign To Options */} - - - - - - - - - - - - - {/* Office App Section */} @@ -853,54 +726,6 @@ export const CippApplicationDeployDrawer = ({ - - {/* Assign To Options */} - - - - - - - - - - - - - {/* Edge App Section */} @@ -938,54 +763,6 @@ export const CippApplicationDeployDrawer = ({ formControl={formControl} /> - - {/* Assign To Options */} - - - - - - - - - - - - - {/* Win32 Script App Section */} @@ -1126,8 +903,11 @@ export const CippApplicationDeployDrawer = ({ formControl={formControl} /> + - {/* Assign To Options */} + {/* Assign To Options: shared by every app type. With one tenant selected, groups are + picked by id from that tenant; with several tenants only names can span them. */} + - + {singleTenant ? ( + + ) : ( + + )} - + {singleTenant ? ( + + ) : ( + + )} diff --git a/src/components/CippComponents/CippBackupScheduleDrawer.jsx b/src/components/CippComponents/CippBackupScheduleDrawer.jsx index 0e85b99871eb..1b8b32e94338 100644 --- a/src/components/CippComponents/CippBackupScheduleDrawer.jsx +++ b/src/components/CippComponents/CippBackupScheduleDrawer.jsx @@ -32,6 +32,7 @@ export const CippBackupScheduleDrawer = ({ intuneprotection: true, antispam: true, antiphishing: true, + teamsvoice: true, CippWebhookAlerts: true, CippScriptedAlerts: true, CippCustomVariables: true, @@ -57,6 +58,7 @@ export const CippBackupScheduleDrawer = ({ intuneprotection: true, antispam: true, antiphishing: true, + teamsvoice: true, CippWebhookAlerts: true, CippScriptedAlerts: true, CippCustomVariables: true, @@ -109,6 +111,7 @@ export const CippBackupScheduleDrawer = ({ intuneprotection: true, antispam: true, antiphishing: true, + teamsvoice: true, CippWebhookAlerts: true, CippScriptedAlerts: true, CippCustomVariables: true, @@ -251,6 +254,19 @@ export const CippBackupScheduleDrawer = ({ /> + + Teams + + + + + + CIPP diff --git a/src/components/CippComponents/CippBecContainmentDrawer.jsx b/src/components/CippComponents/CippBecContainmentDrawer.jsx new file mode 100644 index 000000000000..917a6c05a852 --- /dev/null +++ b/src/components/CippComponents/CippBecContainmentDrawer.jsx @@ -0,0 +1,471 @@ +import { useEffect, useMemo, useState } from 'react' +import { useForm, useWatch } from 'react-hook-form' +import { useQueryClient } from '@tanstack/react-query' +import { + Alert, + Box, + Button, + Chip, + Divider, + Grid, + Stack, + Typography, +} from '@mui/material' +import { CippIcons } from '../../utils/icon-registry' +import { ApiGetCall, ApiPostCall } from '../../api/ApiCall' +import { CippOffCanvas } from './CippOffCanvas' +import CippFormComponent from './CippFormComponent' +import { CippApiResults } from './CippApiResults' + +const impactColor = (impact) => + impact === 'Critical' + ? 'error' + : impact === 'High' + ? 'warning' + : impact === 'Medium' + ? 'info' + : 'default' + +const IMPACT_ORDER = ['Critical', 'High', 'Medium', 'Low'] + +/** + * Selectable BEC containment. Every action in the catalog is a switch grouped by impact; the + * actions that take targets get a picker fed from the run's findings (flagged items preselected). + * A Critical action needs the user's UPN typed before the run button enables. + */ +export const CippBecContainmentDrawer = ({ + userPrincipalName, + userId, + tenantFilter, + caseId, + becData, + buttonText = 'Contain user', + disabled = false, + relatedQueryKeys = [], +}) => { + const [visible, setVisible] = useState(false) + const catalogCall = ApiGetCall({ + url: '/api/ListBECRemediationActions', + queryKey: 'ListBECRemediationActions', + }) + const catalog = useMemo( + () => (Array.isArray(catalogCall.data) ? catalogCall.data : []), + [catalogCall.data] + ) + + const formControl = useForm({ + mode: 'onChange', + defaultValues: { actions: {}, Confirmation: '' }, + }) + const watched = useWatch({ control: formControl.control }) + + // Every target picker's choices, from the run's findings. The reset below preselects the flagged ones. + const options = useMemo( + () => ({ + MfaMethodIds: (becData?.MFADevices || []).map((m) => ({ + label: + `${(m['@odata.type'] || '').replace('#microsoft.graph.', '').replace('AuthenticationMethod', '')} ${m.displayName || ''}`.trim(), + value: m.id, + })), + GrantIds: (becData?.UserGrants || []).map((g) => ({ + label: `${g.ClientDisplayName || g.ClientAppId} (${g.Type}${g.Flagged ? ', flagged' : ''})`, + value: `${g.Type}|${g.Id}`, + })), + ServicePrincipalIds: Array.from( + new Map( + (becData?.UserGrants || []) + .filter( + (g) => g.Risk === 'CatalogMatch' && g.ClientServicePrincipalId + ) + .map((g) => [ + g.ClientServicePrincipalId, + { + label: g.ClientDisplayName || g.ClientServicePrincipalId, + value: g.ClientServicePrincipalId, + }, + ]) + ).values() + ), + RuleIds: (becData?.NewRules || []).map((r) => ({ + label: r.Name, + value: r.Identity || r.Name, + })), + Delegations: (becData?.Delegations || []).map((d, index) => ({ + label: `${d.PermissionType}: ${d.Trustee} (${d.Resource})${d.Flagged ? ' - flagged' : ''}`, + value: String(index), + })), + TransportRuleIds: (becData?.TransportRulesFlagged || []).map((r) => ({ + label: `${r.Name}${r.ChangedInWindow ? ' - changed in window' : ''}`, + value: r.Guid || r.Identity || r.Name, + })), + AddInIds: (becData?.MailboxAddIns || []).map((a) => ({ + label: `${a.DisplayName} (${a.ProviderName || 'unknown provider'})`, + value: a.Identity || a.AppId, + })), + Protocols: [ + 'EWS', + 'IMAP', + 'POP', + 'ActiveSync', + 'OWA', + 'MAPI', + 'ECP', + 'SmtpAuth', + ].map((p) => ({ label: p, value: p })), + MobileDeviceIds: (becData?.SuspectUserDevices || []).map((d) => ({ + label: `${d.DeviceModel || d.DeviceType || 'device'} (${d.DeviceID})`, + value: d.DeviceID, + })), + RegisteredDeviceIds: (becData?.RegisteredDevices || []).map((d) => ({ + label: `${d.displayName || d.deviceId} (${d.operatingSystem || 'unknown OS'}${d.RegisteredInWindow ? ', registered in window' : ''})`, + value: d.id, + })), + BlockSenders: Array.from( + new Set( + (becData?.ReceivedMailFindings || []) + .map((f) => f.SenderAddress) + .filter(Boolean) + ) + ).map((s) => ({ label: s, value: s })), + SharingLinkUrls: Array.from( + new Map( + (becData?.SharingChanges || []) + .filter((c) => c.ItemUrl) + .map((c) => [ + c.ItemUrl, + { label: c.FileName || c.ItemUrl, value: c.ItemUrl }, + ]) + ).values() + ), + }), + [becData] + ) + + // Default selection: the catalog's default set, plus the targeted actions that have flagged findings. + // Re-applied on every open so the drawer always starts from the run's findings. + useEffect(() => { + if (!catalog.length) return + const actions = {} + catalog.forEach((action) => { + actions[action.Id] = !!action.DefaultSelected + }) + formControl.reset({ + actions, + Confirmation: '', + MfaMethodIds: [], + GrantIds: (becData?.UserGrants || []) + .filter((g) => g.Flagged) + .map((g) => ({ + label: `${g.ClientDisplayName || g.ClientAppId} (${g.Type})`, + value: `${g.Type}|${g.Id}`, + })), + ServicePrincipalIds: [], + RuleIds: [], + Delegations: (becData?.Delegations || []) + .map((d, index) => ({ ...d, index })) + .filter((d) => d.Flagged) + .map((d) => ({ + label: `${d.PermissionType}: ${d.Trustee} (${d.Resource})`, + value: String(d.index), + })), + TransportRuleIds: (becData?.TransportRulesFlagged || []) + .filter((r) => r.ChangedInWindow) + .map((r) => ({ label: r.Name, value: r.Guid || r.Identity || r.Name })), + AddInIds: (becData?.MailboxAddIns || []) + .filter((a) => a.Flagged) + .map((a) => ({ label: a.DisplayName, value: a.Identity || a.AppId })), + Protocols: ['EWS', 'IMAP', 'POP', 'ActiveSync', 'SmtpAuth'].map((p) => ({ + label: p, + value: p, + })), + MobileDeviceIds: [], + RegisteredDeviceIds: (becData?.RegisteredDevices || []) + .filter((d) => d.RegisteredInWindow) + .map((d) => ({ + label: `${d.displayName || d.deviceId} (${d.operatingSystem || 'unknown OS'})`, + value: d.id, + })), + BlockSenders: options.BlockSenders, + SharingLinkUrls: options.SharingLinkUrls, + CAState: { label: 'Enabled', value: 'enabled' }, + CAControls: { label: 'Require MFA', value: 'mfa' }, + CAExpiresHours: 24, + }) + }, [catalog, becData, options, visible, formControl]) + + const selectedIds = useMemo( + () => catalog.filter((a) => watched?.actions?.[a.Id]).map((a) => a.Id), + [catalog, watched?.actions] + ) + const criticalSelected = useMemo( + () => + catalog.filter( + (a) => selectedIds.includes(a.Id) && a.Impact === 'Critical' + ), + [catalog, selectedIds] + ) + const confirmationOk = + criticalSelected.length === 0 || + (watched?.Confirmation || '').trim().toLowerCase() === + (userPrincipalName || '').trim().toLowerCase() + + const runCall = ApiPostCall({ + relatedQueryKeys: [`execBECCheck-polling-${caseId}`, ...relatedQueryKeys], + }) + + // The run is a background task reporting per-action progress like offboarding does; once it finishes, refresh + // the case so the remediation history picks up the result. Memoized so CippApiResults does not + // re-arm the poll on every render. + const queryClient = useQueryClient() + const relatedKeysJoined = relatedQueryKeys.join('|') + const jobProgress = useMemo( + () => ({ + idField: 'DeploymentId', + title: 'Remediation progress', + url: (id) => `/api/ListOffboardingProgress?DeploymentId=${id}`, + onComplete: () => + [`execBECCheck-polling-${caseId}`, ...relatedKeysJoined.split('|')] + .filter(Boolean) + .forEach((key) => queryClient.invalidateQueries({ queryKey: [key] })), + }), + [caseId, relatedKeysJoined, queryClient] + ) + + const values = (field) => + (watched?.[field] || []).map((o) => + o && o.value !== undefined ? o.value : o + ) + + const buildPayload = () => { + const grantValues = values('GrantIds') + const delegationIndexes = values('Delegations') + return { + tenantFilter, + userid: userId, + username: userPrincipalName, + CaseId: caseId, + Confirmation: watched?.Confirmation || '', + // always a background run: progress streams into the drawer like offboarding + Async: true, + Actions: selectedIds, + Parameters: { + MfaMethodIds: values('MfaMethodIds'), + GrantIds: grantValues + .filter((v) => String(v).startsWith('DelegatedGrant|')) + .map((v) => String(v).split('|')[1]), + AppRoleAssignmentIds: grantValues + .filter((v) => String(v).startsWith('AppRoleAssignment|')) + .map((v) => String(v).split('|')[1]), + ServicePrincipalIds: values('ServicePrincipalIds'), + RuleIds: values('RuleIds'), + Delegations: delegationIndexes + .map((i) => (becData?.Delegations || [])[Number(i)]) + .filter(Boolean), + TransportRuleIds: values('TransportRuleIds'), + AddInIds: values('AddInIds'), + Protocols: values('Protocols'), + MobileDeviceIds: values('MobileDeviceIds'), + RegisteredDeviceIds: values('RegisteredDeviceIds'), + BlockSenders: values('BlockSenders'), + SharingLinkUrls: values('SharingLinkUrls'), + CAPolicy: { + State: watched?.CAState?.value || 'enabled', + Controls: watched?.CAControls?.value || 'mfa', + ExpiresHours: Number(watched?.CAExpiresHours) || 24, + }, + }, + } + } + + const handleRun = () => { + runCall.mutate({ url: '/api/ExecBECRemediate', data: buildPayload() }) + } + + // The catalog names the parameter each targeted action reads its targets from. + const pickerFor = (action) => { + const name = action.ParameterName + if (name === 'CAPolicy') { + return ( + + + + + + + + + + + + ) + } + if (!name) return null + const available = options[name] || [] + if (available.length === 0 && name !== 'Protocols') { + return ( + + Nothing of this kind was found in the run; nothing will be changed. + + ) + } + return ( + + + + ) + } + + return ( + <> + + setVisible(false)} + size="xl" + footer={ + + + + + + + } + > + + + Pick the actions and their targets, then run. Targets default to the + flagged findings of the{' '} + {caseId ? `run (case ${caseId})` : 'live tenant'}. Actions marked + Critical need the user's UPN typed below before they run. + + {catalogCall.isLoading && ( + Loading actions... + )} + {IMPACT_ORDER.map((impact) => { + const group = catalog.filter((a) => a.Impact === impact) + if (group.length === 0) return null + return ( + + + {impact} + + + + {group.map((action) => ( + + + + {action.Description} + + {watched?.actions?.[action.Id] && ( + {pickerFor(action)} + )} + + ))} + + + + ) + })} + {criticalSelected.length > 0 && ( + + + Critical actions selected:{' '} + {criticalSelected.map((a) => a.Label).join(', ')}. Type{' '} + {userPrincipalName} to confirm. + + + (value || '').trim().toLowerCase() === + (userPrincipalName || '').trim().toLowerCase() || + `Must match ${userPrincipalName}`, + }} + /> + + )} + + + + ) +} + +export default CippBecContainmentDrawer diff --git a/src/components/CippComponents/CippBecCorrelationGraph.jsx b/src/components/CippComponents/CippBecCorrelationGraph.jsx new file mode 100644 index 000000000000..329c7ade5c90 --- /dev/null +++ b/src/components/CippComponents/CippBecCorrelationGraph.jsx @@ -0,0 +1,498 @@ +import { useMemo } from 'react' +import { Box, Chip, Stack, Tooltip, Typography } from '@mui/material' +import { alpha, useTheme } from '@mui/material/styles' +import { + buildBecCorrelationGraph, + BEC_OBJECTIVE_COLOR, + BEC_OBJECTIVE_LABEL, +} from '../../utils/bec-timeline' + +const fmt = (ts) => + new Date(ts).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + +// Column geometry. Four left-to-right lanes: the compromised account, the source IPs it acted from, +// the events from each source, and the other accounts those events reached. +const ACCOUNT_W = 176 +const ACCOUNT_H = 56 +const HUB_W = 184 +const HUB_H = 60 +const EVENT_W = 250 +const EVENT_H = 80 +const TARGET_W = 184 +const TARGET_H = 52 +const ACCOUNT_X = 8 +const HUB_X = 252 +const EVENT_X = 524 +const TARGET_X = 856 +const ROW = EVENT_H + 20 +const CLUSTER_GAP = 28 +const PAD = 24 + +// A horizontal cubic-bezier from one node's right edge to the next node's left edge. +const edgePath = (x1, y1, x2, y2) => { + const dx = Math.max((x2 - x1) * 0.5, 24) + return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}` +} + +const oneLine = { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +} + +// The non-linear view, drawn natively (SVG edges + themed HTML nodes — no graph library). It reads the +// same correlated events as the timeline but groups them by where they came from and who they reached, +// so lateral movement onto other accounts is visible as edges, not buried in a list. +// `fill` swaps the fixed-height scroll box for one that takes the rest of a flex parent (full screen). +export const CippBecCorrelationGraph = ({ + becData, + windowDays = 7, + userData, + fill = false, +}) => { + const theme = useTheme() + + const { nodes, edges, width, height, hasData } = useMemo(() => { + const graph = buildBecCorrelationGraph( + becData, + windowDays, + userData?.userPrincipalName + ) + const accountName = userData?.userPrincipalName || graph.account + const clusters = [ + ...graph.hubs, + ...(graph.orphans.length > 0 + ? [{ ip: null, location: null, foreign: false, events: graph.orphans }] + : []), + ] + if (clusters.length === 0) { + return { nodes: [], edges: [], width: 0, height: 0, hasData: false } + } + + const startId = graph.startOfCompromise?.id + const nodeList = [] + const edgeList = [] + const eventCentre = new Map() + + const neutralStroke = alpha(theme.palette.text.primary, 0.22) + const targetStroke = alpha(theme.palette.text.primary, 0.3) + + // Place each source's events in a vertical block; the hub centres on its block. A running cursor + // stacks the blocks top to bottom so nothing overlaps. + let cursorY = PAD + clusters.forEach((cluster, hubIndex) => { + const hubId = `hub-${hubIndex}` + const count = cluster.events.length + const blockTop = cursorY + cluster.events.forEach((event, eventIndex) => { + eventCentre.set(event.id, blockTop + eventIndex * ROW + EVENT_H / 2) + }) + const hubCentre = blockTop + ((count - 1) * ROW + EVENT_H) / 2 + // an address judged the attacker's is flagged like a foreign one, even at home + const flaggedHub = cluster.foreign || cluster.attacker + const hubColour = cluster.ip + ? flaggedHub + ? theme.palette.error.main + : theme.palette.text.secondary + : theme.palette.text.disabled + + nodeList.push({ + id: hubId, + kind: 'hub', + x: HUB_X, + y: hubCentre - HUB_H / 2, + w: HUB_W, + h: HUB_H, + colour: hubColour, + cluster, + }) + edgeList.push({ + id: `e-account-${hubId}`, + d: edgePath( + ACCOUNT_X + ACCOUNT_W, + 0, // account centre filled in after totalHeight is known + HUB_X, + hubCentre + ), + y1Ref: 'account', + toY: hubCentre, + stroke: flaggedHub ? theme.palette.error.main : neutralStroke, + width: flaggedHub ? 2 : 1, + }) + + cluster.events.forEach((event) => { + const centre = eventCentre.get(event.id) + const colour = BEC_OBJECTIVE_COLOR[event.objective] || '#718096' + nodeList.push({ + id: event.id, + kind: 'event', + x: EVENT_X, + y: centre - EVENT_H / 2, + w: EVENT_W, + h: EVENT_H, + colour, + isStart: event.id === startId, + event, + }) + edgeList.push({ + id: `e-${hubId}-${event.id}`, + d: edgePath(HUB_X + HUB_W, hubCentre, EVENT_X, centre), + stroke: alpha(colour, 0.65), + width: 1, + }) + }) + cursorY += count * ROW + CLUSTER_GAP + }) + + const contentHeight = cursorY - CLUSTER_GAP + PAD + const accountCentre = Math.max(contentHeight / 2, ACCOUNT_H / 2 + PAD) + nodeList.push({ + id: 'account', + kind: 'account', + x: ACCOUNT_X, + y: accountCentre - ACCOUNT_H / 2, + w: ACCOUNT_W, + h: ACCOUNT_H, + name: accountName, + }) + // Now the account centre is known, anchor the account→hub edges' start point to it. + edgeList.forEach((edge) => { + if (edge.y1Ref === 'account') { + edge.d = edgePath(ACCOUNT_X + ACCOUNT_W, accountCentre, HUB_X, edge.toY) + } + }) + + // Affected accounts: centre each on the mean of the events that reached it, then push apart any + // that would overlap. Edges run from each event to the account it touched. + let hasTargets = false + let lastTargetY = -Infinity + const targetCentre = new Map() + const sortedTargets = graph.targets + .map((target) => { + const centres = target.events + .map((event) => eventCentre.get(event.id)) + .filter((value) => typeof value === 'number') + const mean = centres.length + ? centres.reduce((sum, value) => sum + value, 0) / centres.length + : accountCentre + return { ...target, mean } + }) + .sort((a, b) => a.mean - b.mean) + + sortedTargets.forEach((target, index) => { + hasTargets = true + const y = Math.max(target.mean, lastTargetY + TARGET_H + 16) + lastTargetY = y + targetCentre.set(target.account, y) + nodeList.push({ + id: `target-${index}`, + kind: 'target', + x: TARGET_X, + y: y - TARGET_H / 2, + w: TARGET_W, + h: TARGET_H, + target, + }) + }) + + // Event → affected-account edges, drawn once the target centres are settled. + nodeList + .filter((node) => node.kind === 'event' && node.event.affects) + .forEach((node) => { + const y2 = targetCentre.get(node.event.affects) + if (typeof y2 !== 'number') return + edgeList.push({ + id: `e-${node.id}-target`, + d: edgePath( + EVENT_X + EVENT_W, + eventCentre.get(node.event.id), + TARGET_X, + y2 + ), + stroke: targetStroke, + width: 1, + dashed: true, + }) + }) + + const width = hasTargets + ? TARGET_X + TARGET_W + PAD + : EVENT_X + EVENT_W + PAD + const height = Math.max(contentHeight, lastTargetY + TARGET_H / 2 + PAD) + return { nodes: nodeList, edges: edgeList, width, height, hasData: true } + }, [becData, windowDays, userData, theme]) + + if (!hasData) { + return ( + + No timestamped events in the analysis window. + + ) + } + + return ( + + + + The account fans out to each source it acted from, each source to what + was done from it, and those actions out to the other accounts they + reached. Red = foreign or attacker source; ringed = likely start of + compromise. + + {Object.entries(BEC_OBJECTIVE_LABEL).map(([key, label]) => ( + + ))} + + + + + {edges.map((edge) => ( + + ))} + + + {nodes.map((node) => { + if (node.kind === 'account') { + return ( + + + + {node.name} + + + compromised account + + + + ) + } + + if (node.kind === 'hub') { + const { cluster, colour } = node + const title = cluster.ip + ? `${cluster.ip}${cluster.location ? ` · ${cluster.location}` : ''}${cluster.verdict ? ` · verdict: ${cluster.verdict}` : ''}${cluster.foreign ? ' · foreign source' : ''}` + : 'Events with no recorded source IP' + return ( + + + + {cluster.ip + ? `${cluster.foreign ? '🌐 ' : ''}${cluster.ip}` + : 'No source IP'} + + {cluster.location && ( + + {cluster.location} + + )} + + {cluster.events.length} event + {cluster.events.length === 1 ? '' : 's'} + + + + ) + } + + if (node.kind === 'target') { + const { target } = node + return ( + + + + 👥 {target.account} + + + {target.events.length} action + {target.events.length === 1 ? '' : 's'} against + + + + ) + } + + // event + const { event, colour, isStart } = node + const tip = [ + event.label, + fmt(event.ts), + event.graphDetail, + event.affects ? `→ ${event.affects}` : null, + ] + .filter(Boolean) + .join(' · ') + return ( + + + + + {fmt(event.ts)} + + {isStart && ( + + )} + + + {event.label} + + {event.graphDetail && ( + + {event.graphDetail} + + )} + + + ) + })} + + + + ) +} + +export default CippBecCorrelationGraph diff --git a/src/components/CippComponents/CippBecEvidenceDownload.jsx b/src/components/CippComponents/CippBecEvidenceDownload.jsx new file mode 100644 index 000000000000..7511bdb1cc94 --- /dev/null +++ b/src/components/CippComponents/CippBecEvidenceDownload.jsx @@ -0,0 +1,84 @@ +import { useState } from 'react' +import { ApiPostCall } from '../../api/ApiCall' + +const base64ToBlob = (base64, type) => { + const binary = atob(base64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i) + return new Blob([bytes], { type }) +} + +const safe = (value) => + String(value || 'case') + .replace(/[^a-zA-Z0-9._-]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 80) + +const caseOf = (row) => row?.CaseId ?? row?.caseId +const tenantOf = (row) => row?.Tenant ?? row?.tenantFilter +const upnOf = (row) => row?.UserPrincipalName ?? row?.userPrincipalName + +/** + * Downloads a case's evidence package. The export endpoint (ExecBECEvidenceExport) renders the report + * PDFs (the full report and the C-suite summary) server-side and collates the ZIP; the browser just + * requests it by case id and saves it. One hook serves the runs hub (per row) and the case page's + * export button. + */ +export const useBecEvidenceDownload = () => { + const [pendingCaseId, setPendingCaseId] = useState(null) + const [lastError, setLastError] = useState(null) + const exportCall = ApiPostCall({}) + + const download = async (row) => { + const caseId = caseOf(row) + const tenantFilter = tenantOf(row) + if (!caseId || !tenantFilter || pendingCaseId) return + setPendingCaseId(caseId) + setLastError(null) + try { + await new Promise((resolve) => { + exportCall.mutate( + { + url: '/api/ExecBECEvidenceExport', + data: { tenantFilter, caseId }, + }, + { + onSuccess: (result) => { + const evidence = result?.data?.Evidence + if (evidence?.ZipBase64) { + const url = URL.createObjectURL( + base64ToBlob(evidence.ZipBase64, 'application/zip') + ) + const link = document.createElement('a') + link.href = url + link.download = `BEC_Evidence_${safe(upnOf(row) || row?.UserId || caseId)}_${safe(caseId)}.zip` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + } + resolve() + }, + onError: (error) => { + setLastError( + error?.response?.data?.Results || + 'the export failed; see the logbook' + ) + resolve() + }, + } + ) + }) + } catch (error) { + console.error( + 'BEC evidence download failed:', + String(error?.message ?? '').replace(/[\r\n]+/g, ' ') + ) + setLastError(error?.message || 'the export failed; see the logbook') + } finally { + setPendingCaseId(null) + } + } + + return { download, busy: pendingCaseId != null, lastError } +} diff --git a/src/components/CippComponents/CippBecEvidenceExportButton.jsx b/src/components/CippComponents/CippBecEvidenceExportButton.jsx new file mode 100644 index 000000000000..7e38cc61ec70 --- /dev/null +++ b/src/components/CippComponents/CippBecEvidenceExportButton.jsx @@ -0,0 +1,49 @@ +import { Button, Tooltip } from '@mui/material' +import { CippIcons } from '../../utils/icon-registry' +import { useBecEvidenceDownload } from './CippBecEvidenceDownload' + +/** + * Export evidence from the case page: requests the evidence package (the backend renders both report + * PDFs server-side and collates the package into a ZIP) and downloads it. There is no results panel; + * any error shows in the button's hover tooltip, so the control stays a single button in the action row. + */ +export const CippBecEvidenceExportButton = ({ + tenantFilter, + caseId, + userData, +}) => { + const { download, busy, lastError } = useBecEvidenceDownload() + + const tooltip = busy + ? 'Building evidence package…' + : lastError + ? `Last export failed: ${lastError}` + : 'Packages the case evidence (with the report PDFs) as a ZIP' + + return ( + + + + + + ) +} + +export default CippBecEvidenceExportButton diff --git a/src/components/CippComponents/CippBecIPReviewDrawer.jsx b/src/components/CippComponents/CippBecIPReviewDrawer.jsx new file mode 100644 index 000000000000..1f144e8950b6 --- /dev/null +++ b/src/components/CippComponents/CippBecIPReviewDrawer.jsx @@ -0,0 +1,343 @@ +import { useEffect, useMemo, useState } from 'react' +import { useForm, useWatch } from 'react-hook-form' +import { useQueryClient } from '@tanstack/react-query' +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + Grid, + Stack, + Typography, +} from '@mui/material' +import { CippIcons } from '../../utils/icon-registry' +import { ApiPostCall } from '../../api/ApiCall' +import { usePermissions } from '../../hooks/use-permissions' +import { CippOffCanvas } from './CippOffCanvas' +import CippFormComponent from './CippFormComponent' +import { CippApiResults } from './CippApiResults' + +export const BEC_VERDICT_COLOR = { + Compromised: 'error', + LikelyAttacker: 'error', + Suspicious: 'warning', + Unknown: 'default', + LikelyUser: 'success', + Safe: 'success', + Service: 'info', +} +export const BEC_VERDICT_LABEL = { + Compromised: 'Compromised', + LikelyAttacker: 'Likely attacker', + Suspicious: 'Suspicious', + Unknown: 'Unknown', + LikelyUser: 'Likely user', + Safe: 'Safe', + Service: 'Service', +} +const option = (value) => ({ label: value, value }) +const OVERRIDE_OPTIONS = ['Auto', 'Safe', 'Compromised'].map(option) +const valueOf = (field) => field?.value ?? field + +// One address: who it is judged to be, and each reason behind the score with its signed weight - +// red pushes towards the attacker, green towards the user. +const VerdictCard = ({ verdict, index, formControl, disabledNote }) => ( + + + + + + + {verdict.IP} + + + + + {[verdict.City, verdict.Country].filter(Boolean).join(', ')} + {verdict.ASName ? ` · ${verdict.ASName}` : ''} + + + {verdict.Source && verdict.Source !== 'Heuristics' && ( + + Decided by {verdict.Source} + + )} + + {(Array.isArray(verdict.Reasons) ? verdict.Reasons : []).map( + (reason) => ( + + 0 ? 'error' : 'success'} + label={ + reason.Weight > 0 ? `+${reason.Weight}` : reason.Weight + } + sx={{ minWidth: 44, height: 20 }} + /> + {reason.Text} + + ) + )} + + + + + + + + + + + +) + +/** + * Review the IP verdicts of a BEC case: override any address as Safe or Compromised and re-run + * everything that depends on the verdicts in the background (ExecBECIPReview), with live progress + * like containment. Optionally the choices are also saved to CIPP's IP list for the tenant, so later + * cases start from them. Rows are form fields keyed by index - an address has dots, which + * react-hook-form would read as nesting. + */ +export const CippBecIPReviewDrawer = ({ + tenantFilter, + caseId, + becData, + relatedQueryKeys = [], +}) => { + const [visible, setVisible] = useState(false) + const verdicts = useMemo( + () => (Array.isArray(becData?.IPVerdicts) ? becData.IPVerdicts : []), + [becData] + ) + const formControl = useForm({ mode: 'onChange' }) + const watched = useWatch({ control: formControl.control }) + const { checkPermissions } = usePermissions() + const canSaveToList = checkPermissions(['CIPP.AppSettings.ReadWrite']) + + // Every open starts from the case's current overrides. + useEffect(() => { + if (!visible) return + const overrides = Array.isArray(becData?.IPOverrides) + ? becData.IPOverrides + : [] + const byRange = new Map(overrides.map((o) => [o.Range, o])) + formControl.reset({ + Choices: verdicts.map((v) => { + const current = byRange.get(v.IP) + return { + Verdict: option(current?.Verdict || 'Auto'), + Note: current?.Note || '', + } + }), + SaveToList: false, + }) + }, [visible, becData, verdicts, formControl]) + + const choices = useMemo(() => watched?.Choices || [], [watched?.Choices]) + const stored = useMemo( + () => (Array.isArray(becData?.IPOverrides) ? becData.IPOverrides : []), + [becData] + ) + const overrides = useMemo(() => { + const listed = new Set(verdicts.map((v) => v.IP)) + return [ + ...verdicts + .map((v, index) => ({ + IP: v.IP, + Verdict: valueOf(choices[index]?.Verdict) || 'Auto', + Note: choices[index]?.Note || '', + })) + .filter((o) => o.Verdict !== 'Auto'), + // overrides for ranges the list does not show are kept as they are + ...stored + .filter((o) => !listed.has(o.Range)) + .map((o) => ({ IP: o.Range, Verdict: o.Verdict, Note: o.Note || '' })), + ] + }, [verdicts, choices, stored]) + // Only a changed verdict is worth a re-run: the same set again would recompute the same case. + const signature = (list) => + list + .map((o) => `${o.IP ?? o.Range}|${o.Verdict}`) + .sort() + .join(';') + const changed = signature(overrides) !== signature(stored) + // Auto is what the case already ran with, so a re-run needs at least one address set away from it, + // and a set that differs from the one already applied. + const anyDecided = verdicts.some( + (v, index) => (valueOf(choices[index]?.Verdict) || 'Auto') !== 'Auto' + ) + const runBlockedReason = !anyDecided + ? 'Set at least one address to Safe or Compromised to re-run.' + : !changed + ? 'These verdicts are already applied to this case.' + : null + + const reviewCall = ApiPostCall({}) + const trustCall = ApiPostCall({ relatedQueryKeys: ['ListIPWhitelist'] }) + const blockCall = ApiPostCall({ relatedQueryKeys: ['ListIPWhitelist'] }) + const queryClient = useQueryClient() + const relatedKeysJoined = relatedQueryKeys.join('|') + const jobProgress = useMemo( + () => ({ + idField: 'DeploymentId', + title: 'Review progress', + url: (id) => `/api/ListOffboardingProgress?DeploymentId=${id}`, + onComplete: () => + [`execBECCheck-polling-${caseId}`, ...relatedKeysJoined.split('|')] + .filter(Boolean) + .forEach((key) => queryClient.invalidateQueries({ queryKey: [key] })), + }), + [caseId, relatedKeysJoined, queryClient] + ) + + const handleRun = () => { + reviewCall.mutate({ + url: '/api/ExecBECIPReview', + data: { tenantFilter, CaseId: caseId, Overrides: overrides }, + }) + if (canSaveToList && watched?.SaveToList) { + const note = `BEC case ${caseId}` + const safe = overrides + .filter((o) => o.Verdict === 'Safe') + .map((o) => o.IP) + const bad = overrides + .filter((o) => o.Verdict === 'Compromised') + .map((o) => o.IP) + if (safe.length) + trustCall.mutate({ + url: `/api/ExecAddTrustedIP?tenantFilter=${tenantFilter}`, + data: { IP: safe, State: 'Trusted', Note: note }, + }) + if (bad.length) + blockCall.mutate({ + url: `/api/ExecAddTrustedIP?tenantFilter=${tenantFilter}`, + data: { IP: bad, State: 'Blocked', Note: note }, + }) + } + } + + return ( + <> + + setVisible(false)} + size="xl" + footer={ + + + + + + + {canSaveToList && ( + + )} + + + + {runBlockedReason && ( + + {runBlockedReason} + + )} + + + + } + > + + + Set an address to Safe or Compromised to decide it for this case; + Auto keeps the calculated verdict the case already ran with. The + re-run replaces the verdicts, the attacker activity, the delegated + mailboxes and the score. + + {verdicts.length === 0 && ( + + This case has no IP verdicts yet - run a new investigation to + collect them. + + )} + {verdicts.map((v, index) => ( + + ))} + + + + ) +} + +export default CippBecIPReviewDrawer diff --git a/src/components/CippComponents/CippBecObjectiveGroups.jsx b/src/components/CippComponents/CippBecObjectiveGroups.jsx new file mode 100644 index 000000000000..3ad975bfc018 --- /dev/null +++ b/src/components/CippComponents/CippBecObjectiveGroups.jsx @@ -0,0 +1,515 @@ +import { Fragment, useMemo, useState } from 'react' +import { Box, Stack } from '@mui/system' +import { Alert, Button, Chip, Link, Typography } from '@mui/material' +import CippButtonCard from '../CippCards/CippButtonCard' +import { CippDataTable } from '../CippTable/CippDataTable' +import { PropertyList } from '../property-list' +import { PropertyListItem } from '../property-list-item' +import { getIconByName } from '../../utils/icon-registry' +import { getBecIntuneDeviceActions } from './CippIntuneDeviceActions.jsx' +import { CippBecPhishingSpreadDialog } from './CippBecPhishingSpreadDialog' +import { CippApiDialog } from './CippApiDialog' +import { useDialog } from '../../hooks/use-dialog' +import { + BEC_GROUPS, + becGroupFlagged, + becFindingFlags, + becCoverage, + becWindowStart, + joinList, + BEC_FINDING_MARKERS, +} from '../../utils/bec-objectives' + +const arr = (value) => (Array.isArray(value) ? value : []) + +// The evidence half of the case workspace: every finding, grouped by attacker objective, flagged +// first. A check that could not run (missing licence/permission) is shown as "not checked", never as +// a clean pass. A group with flagged findings opens by default; the triage spine can open and scroll +// to any group. Tables are metadata only, exactly what the collectors returned. What each finding +// shows is data on BEC_GROUPS; only three findings need a renderer of their own (below). +export const CippBecObjectiveGroups = ({ + becData, + windowDays, + tenantFilter, + userData, + openGroups, + onToggleGroup, + groupRefs, +}) => { + const [spreadOpen, setSpreadOpen] = useState(false) + const [spread, setSpread] = useState({ sender: '', subject: '' }) + const dismissRiskDialog = useDialog() + const rowActions = useMemo( + () => ({ + intune: getBecIntuneDeviceActions({ tenantFilter }), + // The blast radius: queue a case for each other account an attacker address reached (bulk too). + investigate: [ + { + label: 'Investigate', + type: 'POST', + url: '/api/ExecBECBulkCheck', + icon: getIconByName('TravelExplore'), + data: { UserIds: 'UserId', tenantFilter: `!${tenantFilter}` }, + multiPost: true, + confirmText: + 'Queue a Business Email Compromise investigation for the selected account(s)? Each case appears on the Business Email Compromise page as it finishes.', + condition: (row) => !!row.UserId, + }, + ], + // Blocking a sender now lives in the containment drawer (tenant-wide, catalog-driven). The one + // row action left scopes the phishing wave: it pre-fills the spread search with this message's + // sender and subject and runs it, so "who else got this" is one click. + received: [ + { + label: 'Who else got this email?', + noConfirm: true, + customFunction: (row) => { + setSpread({ + sender: row.SenderAddress || '', + subject: row.Subject || '', + }) + setSpreadOpen(true) + }, + }, + ], + }), + [tenantFilter] + ) + + const ctx = useMemo( + () => ({ windowDays, windowStart: becWindowStart(becData, windowDays) }), + [becData, windowDays] + ) + const counts = useMemo( + () => becGroupFlagged(becData, windowDays), + [becData, windowDays] + ) + const flags = useMemo( + () => becFindingFlags(becData, windowDays), + [becData, windowDays] + ) + + if (!becData) return null + + const completeness = becData.Completeness || {} + const table = (data, columns, actions) => { + if (!data || data.length === 0) return null + // "More Info" per row: the offcanvas renders every field of the record as a full-value property + // list, so the detail — a rule's whole description, a sign-in's device/app/IP — is one click away + // instead of a resized column. A row click opens it too. Fields are the union of keys across the rows. + const extendedInfoFields = [ + ...new Set(data.flatMap((row) => Object.keys(row || {}))), + ] + return ( + + + + ) + } + const muted = (text, sx) => ( + + {text} + + ) + const subHeader = (title) => ( + + {title} + + ) + + // The three findings whose shape is not tables. Header and coverage note are owned by + // renderFinding, so these render nothing when the check was skipped or failed. + const custom = { + risk: () => { + const rs = becData.RiskState + return ( + <> + {muted( + rs?.Listed + ? `Listed as ${rs.RiskState} at ${rs.RiskLevel} risk (${rs.RiskDetail || 'no detail'}).` + : 'Not listed as risky.' + )} + {table(arr(rs?.Detections), [ + 'DetectedDateTime', + 'RiskEventType', + 'RiskLevel', + 'RiskState', + 'IPAddress', + 'Country', + 'City', + 'Activity', + ])} + {rs?.Listed && userData && ( + + + + + )} + + ) + }, + mailboxState: () => { + const ms = becData.MailboxState + if (!ms) return null + const protocols = ['OWA', 'EWS', 'IMAP', 'POP', 'MAPI', 'ActiveSync'] + .filter((p) => ms[`${p}Enabled`] === true) + .join(', ') + return ( + + + + + + + + ) + }, + received: () => { + const findings = arr(becData.ReceivedMailFindings) + const defender = arr(becData.DefenderDetections).map((r) => ({ + ...r, + ThreatTypes: joinList(r.ThreatTypes), + })) + return ( + <> + + + + {findings.length === 0 && + muted('No phishing-shaped or look-alike senders found.', { mt: 1 })} + {table( + findings, + [ + 'Received', + 'FindingType', + 'Severity', + 'SenderAddress', + 'Subject', + 'Reason', + 'Status', + ], + rowActions.received + )} + {defender.length > 0 && + subHeader('Defender for Office 365 detections')} + {table(defender, [ + 'ReceivedDateTime', + 'SenderAddress', + 'Subject', + 'ThreatTypes', + 'DeliveryAction', + 'LatestDeliveryLocation', + 'Delivered', + ])} + + ) + }, + } + + // Everything else is data on the finding: a summary line, a warning, the main table, titled + // sections below it, and the prose for an empty result. + const generic = (finding) => { + const rows = finding.rows + ? arr(finding.rows(becData, ctx)) + : arr(becData[finding.key]) + const sections = arr(finding.sections) + .map((s) => ({ + ...s, + title: typeof s.title === 'function' ? s.title(ctx) : s.title, + rows: arr(s.rows(becData, ctx)), + })) + .filter((s) => s.rows.length > 0) + const summary = finding.summary?.(becData, ctx) + const alert = finding.alert?.(becData, ctx) + const nothing = rows.length === 0 && sections.length === 0 + return ( + <> + {summary && muted(summary)} + {alert && ( + + {alert} + + )} + {nothing && !summary && muted(finding.empty || 'Nothing found.')} + {table(rows, finding.columns, rowActions[finding.actions])} + {sections.map((s) => ( + + {subHeader(s.title)} + {table(s.rows, s.columns)} + + ))} + + ) + } + + // One coverage note per finding: skipped (missing entitlement) reads as "not checked", a hard + // failure as "couldn't check", a cap as "partial". A skipped/failed check renders no content, so an + // empty section is never mistaken for a clean one. + const coverageOf = (finding) => + becCoverage(completeness, BEC_FINDING_MARKERS[finding.key] || []) + + const renderFinding = (finding) => { + const cov = coverageOf(finding) + const fl = flags[finding.key] + // Only suppress content when every check behind the finding was blocked; a finding with some + // checks still complete (phishing ran, Defender skipped) keeps its content and adds the note. + const blocked = cov.allBlocked + return ( + + + {finding.title} + {fl ? ( + + ) : cov.allBlocked ? ( + + ) : cov.state !== 'ok' ? ( + + ) : finding.count?.(becData, ctx) > 0 ? ( + + ) : ( + + )} + + {fl && ( + + Flagged: {fl.reason}. + + )} + {finding.note && ( + + {finding.note} + + )} + {fl && finding.remediation && ( + + {finding.remediation.text} + {finding.remediation.links?.map((link) => ( + + {' '} + + {link.label} + + + ))} + + )} + {cov.state === 'skipped' && + (cov.allBlocked ? ( + + Not checked —{' '} + {cov.requirement || + 'a licence, permission, mailbox or service that is not present'} + . This is not a pass; the result is unknown. + + ) : ( + + Some checks here could not run ( + {cov.requirement || 'missing a licence, permission or service'}); + the rest is shown below. + + ))} + {cov.state === 'failed' && ( + + Couldn't check: {cov.error} + + )} + {cov.state === 'partial' && ( + + Partial results — {cov.cap}. + + )} + {!blocked && + (finding.custom ? custom[finding.custom]?.() : generic(finding))} + + ) + } + + const la = becData.LocationAnalysis + + return ( + + {BEC_GROUPS.map((group) => { + const flagged = counts[group.id] || 0 + const notChecked = group.findings.filter((f) => { + const s = coverageOf(f).state + return s === 'skipped' || s === 'failed' + }).length + const GroupIcon = getIconByName(group.icon, { fontSize: 'small' }) + return ( + (groupRefs.current[group.id] = el)}> + onToggleGroup(group.id, exp)} + title={ + + + {GroupIcon} + {group.title} + + + {flagged > 0 && ( + + )} + {notChecked > 0 && ( + + )} + {flagged === 0 && notChecked === 0 && ( + + )} + + + } + > + + {group.blurb} + + {group.id === 'access' && la && ( + + + + `${c.Country} (${c.Count})`) + .join(', ') || 'none recorded' + } + /> + + + + )} + {group.findings.map(renderFinding)} + + + ) + })} + + setSpreadOpen(false)} + tenantFilter={tenantFilter} + defaultSender={spread.sender} + defaultSubject={spread.subject} + key={`${spread.sender}|${spread.subject}`} + /> + + ) +} + +export default CippBecObjectiveGroups diff --git a/src/components/CippComponents/CippBecPhishingSpreadDialog.jsx b/src/components/CippComponents/CippBecPhishingSpreadDialog.jsx new file mode 100644 index 000000000000..3e1191c879c2 --- /dev/null +++ b/src/components/CippComponents/CippBecPhishingSpreadDialog.jsx @@ -0,0 +1,152 @@ +import { useState } from 'react' +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Grid, + Stack, + TextField, + Typography, + Chip, +} from '@mui/material' +import { ApiGetCall } from '../../api/ApiCall' +import { CippDataTable } from '../CippTable/CippDataTable' + +/** + * "Who else got this?" - groups the recipients of a sender from message-trace metadata so a + * phishing wave can be scoped before anyone is notified or anything is purged. + */ +export const CippBecPhishingSpreadDialog = ({ + open, + onClose, + tenantFilter, + defaultSender = '', + defaultSubject = '', +}) => { + const [sender, setSender] = useState(defaultSender) + const [subject, setSubject] = useState(defaultSubject) + const [days, setDays] = useState(7) + // Opened from a finding row (parent remounts by sender+subject): pre-fill the fields and run the + // trace straight away so "who else got this" needs no extra click. The manual "Trace a sender's + // spread" button opens with an empty sender, so the query stays null until the operator types one. + const [query, setQuery] = useState( + defaultSender + ? { sender: defaultSender, subject: defaultSubject, days: 7 } + : null + ) + + const spreadCall = ApiGetCall({ + url: '/api/ListBECPhishingSpread', + data: { + tenantFilter, + sender: query?.sender, + subject: query?.subject, + days: query?.days, + }, + queryKey: `ListBECPhishingSpread-${tenantFilter}-${query?.sender}-${query?.subject}-${query?.days}`, + waiting: !!query?.sender, + }) + + const result = spreadCall.data + + return ( + + Phishing spread from a sender + + + + Lists every recipient of mail from the sender in the period, from + message-trace metadata only, split into internal and external. Use + it to scope a wave: who to warn, and which mailboxes a Purview + search should cover. + + + + setSender(e.target.value)} + /> + + + setSubject(e.target.value)} + /> + + + setDays(Number(e.target.value) || 7)} + /> + + + + + + {spreadCall.isFetching && ( + Tracing... + )} + {result?.Recipients && ( + <> + + + + + {result.Complete === false && ( + + )} + + + + )} + {result?.Results && typeof result.Results === 'string' && ( + + {result.Results} + + )} + + + + + + + ) +} + +export default CippBecPhishingSpreadDialog diff --git a/src/components/CippComponents/CippBecRemediationHistory.jsx b/src/components/CippComponents/CippBecRemediationHistory.jsx new file mode 100644 index 000000000000..782ec522f4be --- /dev/null +++ b/src/components/CippComponents/CippBecRemediationHistory.jsx @@ -0,0 +1,63 @@ +import { Box, Chip, Stack, Typography } from '@mui/material' +import CippButtonCard from '../CippCards/CippButtonCard' +import { CippDataTable } from '../CippTable/CippDataTable' + +// The containment actions run for this case and their per-target results, newest first. Reads the +// history persisted on the run (becData.Run.Containment); renders nothing until something has run. +// An accordion like the objective groups, open by default. +export const CippBecRemediationHistory = ({ becData }) => { + const history = [...(becData?.Run?.Containment || [])].reverse() + if (history.length === 0) return null + + return ( + + Remediation taken + + + } + > + + Containment actions run for this case and their results, newest first. + + + {history.map((entry, index) => { + const results = Array.isArray(entry.Results) ? entry.Results : [] + return ( + + + {entry.At + ? new Date(entry.At).toLocaleString() + : 'Unknown time'}{' '} + · {entry.By || 'CIPP'} · {(entry.Actions || []).length}{' '} + action(s) + + {results.length > 0 ? ( + + ) : ( + + No per-action results were recorded. + + )} + + ) + })} + + + ) +} + +export default CippBecRemediationHistory diff --git a/src/components/CippComponents/CippBecTimelineCustom.jsx b/src/components/CippComponents/CippBecTimelineCustom.jsx new file mode 100644 index 000000000000..e02113adceb6 --- /dev/null +++ b/src/components/CippComponents/CippBecTimelineCustom.jsx @@ -0,0 +1,138 @@ +import { useMemo } from 'react' +import { + Timeline, + TimelineItem, + TimelineSeparator, + TimelineConnector, + TimelineContent, + TimelineDot, +} from '@mui/lab' +import { Box, Chip, Stack, Typography } from '@mui/material' +import { + buildBecTimeline, + BEC_OBJECTIVE_COLOR, + BEC_OBJECTIVE_LABEL, +} from '../../utils/bec-timeline' + +const fmt = (ts) => + new Date(ts).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + +// Evaluation version A: a compact MUI Timeline. The empty opposite-content half is removed so the +// content uses the full width; each event is one dense row (time + label on a line, detail beneath) +// with a dot coloured by attacker objective. The start-of-compromise event is ringed and tagged. +export const CippBecTimelineCustom = ({ becData, windowDays = 7 }) => { + const { events, startOfCompromise } = useMemo( + () => buildBecTimeline(becData, windowDays), + [becData, windowDays] + ) + + if (events.length === 0) { + return ( + + No timestamped events in the analysis window. + + ) + } + + return ( + + + {Object.entries(BEC_OBJECTIVE_LABEL).map(([key, label]) => ( + + ))} + + + {events.map((event, index) => { + const colour = BEC_OBJECTIVE_COLOR[event.objective] || '#718096' + const isStart = startOfCompromise && event.id === startOfCompromise.id + return ( + + + + {index < events.length - 1 && ( + + )} + + + + + {fmt(event.ts)} + + + {event.label} + + {isStart && ( + + )} + + {event.detail && ( + + {event.detail} + + )} + + + ) + })} + + + ) +} + +export default CippBecTimelineCustom diff --git a/src/components/CippComponents/CippBecTimelineEvaluator.jsx b/src/components/CippComponents/CippBecTimelineEvaluator.jsx new file mode 100644 index 000000000000..b7234194725e --- /dev/null +++ b/src/components/CippComponents/CippBecTimelineEvaluator.jsx @@ -0,0 +1,152 @@ +import { useState } from 'react' +import { + Box, + Button, + Dialog, + DialogContent, + DialogTitle, + IconButton, + Stack, + ToggleButton, + ToggleButtonGroup, + Tooltip, + Typography, +} from '@mui/material' +import { CippIcons } from '../../utils/icon-registry' +import { CippBecTimelineCustom } from './CippBecTimelineCustom' +import { CippBecCorrelationGraph } from './CippBecCorrelationGraph' + +const VERSIONS = [ + { key: 'timeline', label: 'Timeline' }, + { key: 'graph', label: 'Correlation graph' }, +] + +// Two takes on the same correlated events: a compact vertical timeline, and a non-linear graph that +// groups events by the source they came from and the accounts they reached. Both render natively and +// follow the app theme; the toggle just swaps which one shows. Full screen opens the same view in a +// full-screen dialog (the graph then fills the screen instead of its fixed-height scroll box). +export const CippBecTimelineEvaluator = ({ + becData, + windowDays = 7, + userData, +}) => { + const [version, setVersion] = useState('timeline') + const [fullScreen, setFullScreen] = useState(false) + + const toggle = ( + value && setVersion(value)} + > + {VERSIONS.map((option) => ( + + {option.label} + + ))} + + ) + + const view = (fill) => + version === 'timeline' ? ( + + ) : ( + + ) + + return ( + + + + The same correlated events as a dense timeline, or as a graph grouped + by attacker source and the accounts it reached. + + + {toggle} + + + + {!fullScreen && view(false)} + + setFullScreen(false)} + aria-labelledby="bec-timeline-fullscreen-title" + > + + + Attack timeline + + {toggle} + + setFullScreen(false)} + > + + + + + + + + {fullScreen && view(true)} + + + + ) +} + +export default CippBecTimelineEvaluator diff --git a/src/components/CippComponents/CippExchangeActions.jsx b/src/components/CippComponents/CippExchangeActions.jsx index b63adc5712e2..fe4511dca2c6 100644 --- a/src/components/CippComponents/CippExchangeActions.jsx +++ b/src/components/CippComponents/CippExchangeActions.jsx @@ -134,7 +134,7 @@ export const CippExchangeActions = () => { }, { label: "Research Compromised Account", - link: "/identity/administration/users/user/bec?userId=[ExternalDirectoryObjectId]", + link: "/identity/administration/bec/case?userId=[ExternalDirectoryObjectId]", color: "info", icon: , }, diff --git a/src/components/CippComponents/CippFormComponent.jsx b/src/components/CippComponents/CippFormComponent.jsx index 337f4d063273..f98600b7df38 100644 --- a/src/components/CippComponents/CippFormComponent.jsx +++ b/src/components/CippComponents/CippFormComponent.jsx @@ -651,6 +651,15 @@ export const CippFormComponent = (props) => { creatable: false, }); + // Single ISO 3166-1 alpha-2 country code (e.g. the UsageLocation standard). + case "CountryCodeSelect": + return renderAutoCompleteField({ + ...other, + options: countryCodeOptions, + multiple: false, + creatable: false, + }); + // ISO 639-1 alpha-2 language code multiselect (e.g. Spam Filter LanguageBlockList). case "LanguageCodeMultiSelect": return renderAutoCompleteField({ diff --git a/src/components/CippComponents/CippIntunePolicyActions.jsx b/src/components/CippComponents/CippIntunePolicyActions.jsx index d214b256cb2f..1169d162b017 100644 --- a/src/components/CippComponents/CippIntunePolicyActions.jsx +++ b/src/components/CippComponents/CippIntunePolicyActions.jsx @@ -15,6 +15,67 @@ const assignmentDirectionOptions = [ { label: 'Exclude these group(s)', value: 'exclude' }, ] +// Group picker (by ID) for one tenant. Shared by the assign row actions below and by the +// policy/app deploy drawers, which pass the drawer's selected tenant so the list is not the +// page tenant. Same queryKey as the row actions so the two share one cache per tenant. +export const getGroupPickerField = (tenant, name, label, required) => ({ + type: 'autoComplete', + name, + label, + multiple: true, + creatable: false, + allowResubmit: true, + ...(required && { validators: { required: 'Please select at least one group' } }), + api: { + url: '/api/ListGraphRequest', + dataKey: 'Results', + tenantFilter: tenant, + queryKey: `ListPolicyAssignmentGroups-${tenant}`, + labelField: (group) => (group.id ? `${group.displayName} (${group.id})` : group.displayName), + valueField: 'id', + addedField: { + description: 'description', + displayName: 'displayName', + }, + data: { + Endpoint: 'groups', + manualPagination: true, + $select: 'id,displayName,description', + $orderby: 'displayName', + $top: 999, + $count: true, + }, + }, +}) + +// The deploy drawers offer the picker only when exactly one tenant is selected; group ids are +// tenant-scoped, and with several tenants only names can span them. +export const getSingleDeployTenant = (selectedTenants) => + Array.isArray(selectedTenants) && + selectedTenants.length === 1 && + selectedTenants[0]?.value !== 'AllTenants' + ? selectedTenants[0].value + : undefined + +// Turn picked group options into the deploy payload. The body carries both the ids and the +// legacy name fields: every backend hop derives AssignTo from customGroup and the resolvers +// prefer the ids, so names stay for log text and as an exact-name fallback. +export const applyPickedGroups = (formData) => { + const { groupTargets, excludeGroupTargets, ...data } = formData + const nameOf = (group) => group?.addedFields?.displayName ?? group?.label + const include = Array.isArray(groupTargets) ? groupTargets : [] + const exclude = Array.isArray(excludeGroupTargets) ? excludeGroupTargets : [] + if (include.length > 0) { + data.customGroup = include.map(nameOf).join(', ') + data.GroupIds = include.map((group) => group.value).filter(Boolean) + } + if (exclude.length > 0) { + data.excludeGroup = exclude.map(nameOf).join(', ') + data.ExcludeGroupIds = exclude.map((group) => group.value).filter(Boolean) + } + return data +} + /** * Get assignment actions for Intune policies * @param {string} tenant - The tenant filter @@ -40,35 +101,6 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) => templateData = null, } = options - // Group picker (by ID) reused for both include and exclude selection - const getGroupPickerField = (name, label, required) => ({ - type: 'autoComplete', - name, - label, - multiple: true, - creatable: false, - allowResubmit: true, - ...(required && { validators: { required: 'Please select at least one group' } }), - api: { - url: '/api/ListGraphRequest', - dataKey: 'Results', - queryKey: `ListPolicyAssignmentGroups-${tenant}`, - labelField: (group) => (group.id ? `${group.displayName} (${group.id})` : group.displayName), - valueField: 'id', - addedField: { - description: 'description', - }, - data: { - Endpoint: 'groups', - manualPagination: true, - $select: 'id,displayName,description', - $orderby: 'displayName', - $top: 999, - $count: true, - }, - }, - }) - // Assignment mode + optional device filter, shared by every assign action. const getOptionsAndFilterFields = (modeHelperText) => [ { @@ -121,7 +153,7 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) => type: 'heading', label: 'Exclude groups (optional)', }, - getGroupPickerField('excludeGroupTargets', 'Exclude group(s)', false), + getGroupPickerField(tenant, 'excludeGroupTargets', 'Exclude group(s)', false), ...getOptionsAndFilterFields(), ] @@ -132,7 +164,7 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) => label: 'Target groups', }, { - ...getGroupPickerField('groupTargets', 'Group(s)', false), + ...getGroupPickerField(tenant, 'groupTargets', 'Group(s)', false), helperText: 'Leave empty with Exclude + Replace to remove all exclusions (keeps includes).', validators: { // Required, except Exclude + Replace where an empty selection clears all exclusions. diff --git a/src/components/CippComponents/CippPageActionsFab.jsx b/src/components/CippComponents/CippPageActionsFab.jsx index e85286c55527..50f2570b00b4 100644 --- a/src/components/CippComponents/CippPageActionsFab.jsx +++ b/src/components/CippComponents/CippPageActionsFab.jsx @@ -19,7 +19,7 @@ import { } 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 +// sheet of actions. CippSpeedDial cedes this corner below lg, 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}. diff --git a/src/components/CippComponents/CippPolicyDeployDrawer.jsx b/src/components/CippComponents/CippPolicyDeployDrawer.jsx index b5812cdc9e71..2fdee1e5063b 100644 --- a/src/components/CippComponents/CippPolicyDeployDrawer.jsx +++ b/src/components/CippComponents/CippPolicyDeployDrawer.jsx @@ -11,6 +11,11 @@ import { CippFormCondition } from './CippFormCondition' import { CippApiResults } from './CippApiResults' import { useSettings } from '../../hooks/use-settings' import { CippFormTenantSelector } from './CippFormTenantSelector' +import { + applyPickedGroups, + getGroupPickerField, + getSingleDeployTenant, +} from './CippIntunePolicyActions' const assignmentFilterTypeOptions = [ { label: 'Include - Apply policy to devices matching filter', value: 'include' }, @@ -65,6 +70,18 @@ export const CippPolicyDeployDrawer = ({ const { isValid } = useFormState({ control: formControl.control }) const tenantFilter = useSettings()?.currentTenant const selectedTenants = useWatch({ control: formControl.control, name: 'tenantFilter' }) || [] + // With exactly one tenant selected, groups are picked by id from that tenant. Otherwise the + // name fields stay, since only names can span tenants. + const groupTenant = getSingleDeployTenant(selectedTenants) + const singleTenant = Boolean(groupTenant) + // A group selection is only valid for the tenant it was loaded from, and a stale name from + // the other mode must not ship either. + useEffect(() => { + formControl.setValue('groupTargets', []) + formControl.setValue('excludeGroupTargets', []) + formControl.setValue('customGroup', '') + formControl.setValue('excludeGroup', '') + }, [groupTenant, formControl]) const CATemplates = ApiGetCall({ url: '/api/ListIntuneTemplates', queryKey: 'IntuneTemplates' }) const [JSONData, setJSONData] = useState() const watcher = useWatch({ control: formControl.control, name: 'TemplateList' }) @@ -99,7 +116,9 @@ export const CippPolicyDeployDrawer = ({ return } - const formData = formControl.getValues() + const formData = singleTenant + ? applyPickedGroups(formControl.getValues()) + : formControl.getValues() const assignmentFilterName = formData?.assignmentFilter?.value || null const assignmentFilterType = assignmentFilterName ? formData?.assignmentFilterType || 'include' @@ -230,13 +249,20 @@ export const CippPolicyDeployDrawer = ({ compareValue="customGroup" > - + {singleTenant ? ( + + ) : ( + + )} - + {singleTenant ? ( + + ) : ( + + )} + {/* Teams */} + + Teams + + + {/* CIPP */} CIPP diff --git a/src/components/CippComponents/CippRestoreWizard.jsx b/src/components/CippComponents/CippRestoreWizard.jsx index 53f63285ea32..430d7799b589 100644 --- a/src/components/CippComponents/CippRestoreWizard.jsx +++ b/src/components/CippComponents/CippRestoreWizard.jsx @@ -47,7 +47,6 @@ const TABLE_LABELS = { WebhookRules: "Webhook Rules", "templates:AppApprovalTemplate": "App Approval Templates", "templates:AssignmentFilterTemplate": "Assignment Filter Templates", - "templates:BPATemplate": "BPA Templates", "templates:CATemplate": "Conditional Access Templates", "templates:ConnectionfilterTemplate": "Connection Filter Templates", "templates:ContactTemplate": "Contact Templates", diff --git a/src/components/CippComponents/CippRoleAssignmentActions.jsx b/src/components/CippComponents/CippRoleAssignmentActions.jsx index 48bf86041fda..133ee045d5a3 100644 --- a/src/components/CippComponents/CippRoleAssignmentActions.jsx +++ b/src/components/CippComponents/CippRoleAssignmentActions.jsx @@ -187,6 +187,8 @@ export const useCippRoleAssignmentActions = ({ confirmPhrase: globalAdminPhrase, relatedQueryKeys, condition: (row) => canWriteRole && row?.PIMCapable && isDirect(row), + // Exactly one of the two removal actions is shown per row; condition stays for bulk. + hideCondition: (row) => row?.PIMCapable === false, }, { // Tenants without Entra ID P2 have no PIM API; removal goes through the directoryRoles @@ -219,6 +221,7 @@ export const useCippRoleAssignmentActions = ({ relatedQueryKeys: [...relatedQueryKeys, 'ListRoles*'], condition: (row) => canWriteRole && row?.PIMCapable === false && isDirect(row), + hideCondition: (row) => row?.PIMCapable !== false, }, ] } diff --git a/src/components/CippComponents/CippSharePointTemplateBuilder.jsx b/src/components/CippComponents/CippSharePointTemplateBuilder.jsx index a029a10b8c66..dc4fee7283d4 100644 --- a/src/components/CippComponents/CippSharePointTemplateBuilder.jsx +++ b/src/components/CippComponents/CippSharePointTemplateBuilder.jsx @@ -17,11 +17,14 @@ import { MenuItem, Skeleton, Stack, + Tab, + Tabs, Tooltip, Typography, Button, } from "@mui/material"; import CippFormComponent from "./CippFormComponent"; +import { CippCardTabPanel } from "./CippCardTabPanel"; import { CippSharePointPermissionEditor } from "./CippSharePointPermissionEditor"; import SharePointIcon from "../../icons/iconly/bulk/sharepoint"; import TeamsIcon from "../../icons/iconly/bulk/teams"; @@ -100,7 +103,10 @@ const siteTypeIcon = (siteType) => (siteType === "teams" ? TeamsIcon : SharePoin // Faint grayscale watermark only; header product marks stay in color. const watermarkTypeIconSx = { filter: "grayscale(1)" }; -const resolveSiteType = (value) => (value === "teams" ? "teams" : "sharePoint"); +const resolveSiteType = (value) => { + const raw = value?.value ?? value; + return raw === "teams" ? "teams" : "sharePoint"; +}; const resolveSiteLanguage = (value) => { const raw = value?.value ?? value; @@ -127,7 +133,11 @@ const resolveCreateAs = (value) => { export { resolveCreateAs }; +const isReservedGeneralName = (name) => (name || "").trim().toLowerCase() === "general"; + const newLibrary = () => ({ name: "", description: "", permissions: [] }); +const newChannel = () => ({ name: "", membershipType: "standard", layoutType: "post" }); +const newFolder = () => ({ name: "" }); const newSiteTemplate = (siteType = "sharePoint") => ({ displayName: "", alias: "", @@ -136,13 +146,32 @@ const newSiteTemplate = (siteType = "sharePoint") => ({ createAs: CREATE_AS_DEFAULT, permissions: [], libraries: [], + channels: [], + folders: [], }); -/** True when a site card has issues that keep Save disabled (name, root perms, library names). */ +/** True when a site card has issues that keep Save disabled (name, root perms, library/channel/folder names). */ export const siteTemplateBlocksSave = (site) => { if (!site?.displayName?.trim()) return true; if (!Array.isArray(site?.permissions) || site.permissions.length === 0) return true; if ((site?.libraries || []).some((lib) => !lib?.name?.trim())) return true; + // Channels/folders only apply to Teams; ignore when this card is SharePoint. + if (resolveSiteType(site?.siteType) === "teams") { + if ( + (site?.channels || []).some( + (ch) => !ch?.name?.trim() || isReservedGeneralName(ch.name) + ) + ) { + return true; + } + if ( + (site?.folders || []).some( + (folder) => !folder?.name?.trim() || isReservedGeneralName(folder.name) + ) + ) { + return true; + } + } return false; }; @@ -160,6 +189,20 @@ export const getSiteTemplateSaveIssues = (sites = []) => { if ((site?.libraries || []).some((lib) => !lib?.name?.trim())) { issues.push(`${label}: every library needs a name`); } + if (resolveSiteType(site?.siteType) === "teams") { + if ((site?.channels || []).some((ch) => !ch?.name?.trim())) { + issues.push(`${label}: every channel needs a name`); + } + if ((site?.channels || []).some((ch) => isReservedGeneralName(ch.name))) { + issues.push(`${label}: channel name "General" is reserved`); + } + if ((site?.folders || []).some((folder) => !folder?.name?.trim())) { + issues.push(`${label}: every folder needs a name`); + } + if ((site?.folders || []).some((folder) => isReservedGeneralName(folder.name))) { + issues.push(`${label}: folder name "General" is reserved`); + } + } }); return issues; }; @@ -269,6 +312,253 @@ const LibraryRow = ({ formControl, name, onRemove, onConfigurePermissions }) => ); }; +// Channel row for Teams cards: name + membership cycle (public/private/shared) + layout toggle (posts/chat). +const CHANNEL_MEMBERSHIP_CYCLE = ["standard", "private", "shared"]; +const channelMembershipMeta = { + standard: { + label: "Public channel", + nextHint: "click for private", + Icon: CippIcons.Public, + color: "text.secondary", + }, + private: { + label: "Private channel", + nextHint: "click for shared", + Icon: CippIcons.Lock, + color: "warning.main", + }, + shared: { + label: "Shared channel", + nextHint: "click for public", + Icon: CippIcons.Share, + color: "info.main", + }, +}; +const channelLayoutMeta = { + post: { + label: "Posts layout", + nextHint: "click for chat layout", + Icon: CippIcons.ViewList, + }, + chat: { + label: "Chat layout", + nextHint: "click for posts layout", + Icon: CippIcons.ViewAgenda, + }, +}; + +const ChannelRow = ({ formControl, name, onRemove }) => { + const [anchorEl, setAnchorEl] = useState(null); + const channelName = useWatch({ control: formControl.control, name: `${name}.name` }); + const membershipType = useWatch({ + control: formControl.control, + name: `${name}.membershipType`, + }); + const layoutType = useWatch({ + control: formControl.control, + name: `${name}.layoutType`, + }); + const openMenu = Boolean(anchorEl); + const missingName = !channelName?.trim(); + const reservedName = isReservedGeneralName(channelName); + const membershipKey = CHANNEL_MEMBERSHIP_CYCLE.includes(membershipType) + ? membershipType + : "standard"; + const layoutKey = layoutType === "chat" ? "chat" : "post"; + const membership = channelMembershipMeta[membershipKey]; + const layout = channelLayoutMeta[layoutKey]; + const MembershipIcon = membership.Icon; + const LayoutIcon = layout.Icon; + + return ( + + + + + !isReservedGeneralName(value) || '"General" is reserved', + }} + render={({ field }) => ( + + )} + /> + { + const current = CHANNEL_MEMBERSHIP_CYCLE.includes(field.value) + ? field.value + : "standard"; + const next = + CHANNEL_MEMBERSHIP_CYCLE[ + (CHANNEL_MEMBERSHIP_CYCLE.indexOf(current) + 1) % CHANNEL_MEMBERSHIP_CYCLE.length + ]; + return ( + + field.onChange(next)} + > + + + + ); + }} + /> + ( + + field.onChange(field.value === "chat" ? "post" : "chat")} + > + + + + )} + /> + + setAnchorEl(e.currentTarget)} + > + + + + setAnchorEl(null)}> + { + setAnchorEl(null); + onRemove(); + }} + sx={{ color: "error.main" }} + > + + + + Remove Channel + + + + ); +}; + +// Folder row: created at Documents root next to General (Teams Files tab). +const FolderRow = ({ formControl, name, onRemove }) => { + const [anchorEl, setAnchorEl] = useState(null); + const folderName = useWatch({ control: formControl.control, name: `${name}.name` }); + const openMenu = Boolean(anchorEl); + const missingName = !folderName?.trim(); + const reservedName = isReservedGeneralName(folderName); + + return ( + + + + + !isReservedGeneralName(value) || '"General" is reserved', + }} + render={({ field }) => ( + + )} + /> + + setAnchorEl(e.currentTarget)} + > + + + + setAnchorEl(null)}> + { + setAnchorEl(null); + onRemove(); + }} + sx={{ color: "error.main" }} + > + + + + Remove Folder + + + + ); +}; + // Site type editor shown from the site card "..." menu. const SiteTypeDialog = ({ formControl, name, overrideActive, onClose }) => ( @@ -394,16 +684,19 @@ const CreateAsDialog = ({ formControl, name, onClose }) => { ); }; -// A single site template card: coloured header, libraries, and "..." menu for permissions, -// site type, and remove. Header icon + watermark reflect the effective site type. +// A single site template card: coloured header, tabbed body (libraries / channels / folders +// for Teams; libraries only for SharePoint), and "..." menu for permissions, site type, and remove. const SiteTemplateCard = ({ formControl, name, index, onRemove, onConfigurePermissions }) => { const [anchorEl, setAnchorEl] = useState(null); const [siteTypeOpen, setSiteTypeOpen] = useState(false); const [siteLanguageOpen, setSiteLanguageOpen] = useState(false); const [createAsOpen, setCreateAsOpen] = useState(false); + const [bodyTab, setBodyTab] = useState(0); const permissions = useWatch({ control: formControl.control, name: `${name}.permissions` }); const displayName = useWatch({ control: formControl.control, name: `${name}.displayName` }); const libraries = useWatch({ control: formControl.control, name: `${name}.libraries` }); + const channels = useWatch({ control: formControl.control, name: `${name}.channels` }); + const folders = useWatch({ control: formControl.control, name: `${name}.folders` }); const cardSiteType = useWatch({ control: formControl.control, name: `${name}.siteType` }); const createAs = useWatch({ control: formControl.control, name: `${name}.createAs` }); const overrideSiteType = useWatch({ control: formControl.control, name: "overrideSiteType" }); @@ -412,24 +705,91 @@ const SiteTemplateCard = ({ formControl, name, index, onRemove, onConfigurePermi const openMenu = Boolean(anchorEl); const overrideActive = !!overrideSiteType; const effectiveSiteType = resolveSiteType(overrideActive ? templateSiteType : cardSiteType); + const isTeams = effectiveSiteType === "teams"; const TypeIcon = siteTypeIcon(effectiveSiteType); const createAsLabel = - effectiveSiteType === "teams" + isTeams ? "Microsoft Team" : resolveCreateAs(createAs) === "Communication" ? "Communication site" : "Team site"; - const { fields, append, remove } = useFieldArray({ + const { + fields: libraryFields, + append: appendLibrary, + remove: removeLibrary, + } = useFieldArray({ control: formControl.control, name: `${name}.libraries`, }); + const { + fields: channelFields, + append: appendChannel, + remove: removeChannel, + } = useFieldArray({ + control: formControl.control, + name: `${name}.channels`, + }); + const { + fields: folderFields, + append: appendFolder, + remove: removeFolder, + } = useFieldArray({ + control: formControl.control, + name: `${name}.folders`, + }); + + // Keep body tab in range when switching SharePoint <-> Teams. + useEffect(() => { + if (!isTeams && bodyTab !== 0) setBodyTab(0); + }, [isTeams, bodyTab]); // Flag anything on this card that keeps Save disabled (same rules as form + save checks). const missingDisplayName = !displayName?.trim(); const missingRootPerms = permCount === 0; const incompleteLibraries = (libraries || []).some((lib) => !lib?.name?.trim()); - const cardBlocksSave = missingDisplayName || missingRootPerms || incompleteLibraries; + const incompleteChannels = + isTeams && + (channels || []).some((ch) => !ch?.name?.trim() || isReservedGeneralName(ch.name)); + const incompleteFolders = + isTeams && + (folders || []).some( + (folder) => !folder?.name?.trim() || isReservedGeneralName(folder.name) + ); + const cardBlocksSave = + missingDisplayName || + missingRootPerms || + incompleteLibraries || + incompleteChannels || + incompleteFolders; + + const libraryCount = Array.isArray(libraries) ? libraries.length : 0; + const channelCount = Array.isArray(channels) ? channels.length : 0; + const folderCount = Array.isArray(folders) ? folders.length : 0; + + const tabLabel = (short, count) => ( + + {short} + {count > 0 && ( + + {count} + + )} + + ); + + const addRowSx = { + display: "flex", + alignItems: "center", + gap: 1, + px: 1, + py: 0.75, + mt: 0.5, + borderRadius: 1, + cursor: "pointer", + color: "primary.main", + "&:hover": { bgcolor: "action.hover" }, + }; return ( - {/* Body: document libraries + faint type watermark */} + {/* Body: tabbed libraries / channels / folders + faint type watermark */} - - Document Libraries - - - {fields.map((field, libIndex) => ( - remove(libIndex)} - onConfigurePermissions={() => - onConfigurePermissions(`${name}.libraries.${libIndex}.permissions`, "Library") - } - /> - ))} - append(newLibrary())} + {isTeams ? ( + setBodyTab(value)} + variant="fullWidth" sx={{ - display: "flex", - alignItems: "center", - gap: 1, - px: 1, - py: 0.75, - mt: 0.5, - borderRadius: 1, - cursor: "pointer", - color: "primary.main", - "&:hover": { bgcolor: "action.hover" }, + minHeight: 36, + borderBottom: 1, + borderColor: "divider", + position: "relative", + zIndex: 1, + "& .MuiTab-root": { minHeight: 36, py: 0.5, px: 0.5, fontSize: 12 }, }} > - - Add Library - - + + + + + ) : ( + + Document Libraries + + )} + + + + {libraryFields.map((field, libIndex) => ( + removeLibrary(libIndex)} + onConfigurePermissions={() => + onConfigurePermissions(`${name}.libraries.${libIndex}.permissions`, "Library") + } + /> + ))} + appendLibrary(newLibrary())} sx={addRowSx}> + + Add Library + + + + {isTeams && ( + <> + + + {channelFields.map((field, chIndex) => ( + removeChannel(chIndex)} + /> + ))} + appendChannel(newChannel())} sx={addRowSx}> + + Add Channel + + + + + + + Created next to General under Documents + + {folderFields.map((field, folderIndex) => ( + removeFolder(folderIndex)} + /> + ))} + appendFolder(newFolder())} sx={addRowSx}> + + Add Folder + + + + + )} + { (total, site) => total + (Array.isArray(site?.libraries) ? site.libraries.length : 0), 0 ); + // Channels/folders only deploy for Teams; count using the same effective type as deploy. + const channelCount = siteTemplates.reduce((total, site) => { + const effective = overrideActive ? templateSiteType : site?.siteType; + if (resolveSiteType(effective) !== "teams") return total; + return total + (Array.isArray(site?.channels) ? site.channels.length : 0); + }, 0); + const folderCount = siteTemplates.reduce((total, site) => { + const effective = overrideActive ? templateSiteType : site?.siteType; + if (resolveSiteType(effective) !== "teams") return total; + return total + (Array.isArray(site?.folders) ? site.folders.length : 0); + }, 0); const permissionCount = siteTemplates.reduce((total, site) => { const sitePerms = Array.isArray(site?.permissions) ? site.permissions.length : 0; const libPerms = Array.isArray(site?.libraries) @@ -823,7 +1247,7 @@ export const CippSharePointTemplateQuickStats = ({ formControl, sx }) => { // Counts respect the section override so they match what deploy will create. const teamsCount = siteTemplates.filter((site) => { const effective = overrideActive ? templateSiteType : site?.siteType; - return effective === "teams"; + return resolveSiteType(effective) === "teams"; }).length; const sharePointCount = siteTemplates.length - teamsCount; @@ -837,6 +1261,8 @@ export const CippSharePointTemplateQuickStats = ({ formControl, sx }) => { + + ); @@ -851,6 +1277,8 @@ export const CippSharePointTemplateQuickStatsSkeleton = () => ( + + ); @@ -955,8 +1383,8 @@ export const CippSharePointTemplateBuilder = ({ formControl }) => { - Each site template provisions a SharePoint site or Microsoft Team and its document - libraries. + Each site template provisions a SharePoint site or Microsoft Team, plus document + libraries. Teams cards can also define channels and folders under Documents. {overrideActive && ( theme.breakpoints.down('md')) + // account popover there instead (see AccountPopover's navCollapsed section). Page-action + // FABs appear at useIsMobileLayout, so this has to hide at the same threshold or the two + // overlap in between (a half-screen window). + const isMobile = useIsMobileLayout() const formControls = actions.reduce((acc, action) => { if (action.form) { @@ -113,7 +115,7 @@ const CippSpeedDial = ({ } }, [speedDialOpen]) - if (mdDown) { + if (isMobile) { return null } diff --git a/src/components/CippComponents/CippTemplateCatalog.jsx b/src/components/CippComponents/CippTemplateCatalog.jsx index e420b5fab194..73dc1b71e220 100644 --- a/src/components/CippComponents/CippTemplateCatalog.jsx +++ b/src/components/CippComponents/CippTemplateCatalog.jsx @@ -46,7 +46,6 @@ export const templateTypeLabels = { ReportBuilderTemplate: 'Report Builder', GroupTemplate: 'Group', AppApprovalTemplate: 'App Approval', - BPATemplate: 'Best Practices Report', TransportTemplate: 'Transport Rule', ExConnectorTemplate: 'Exchange Connector', AppTemplate: 'Application', diff --git a/src/components/CippComponents/CippTranslations.jsx b/src/components/CippComponents/CippTranslations.jsx index 412b52927156..1f074a59e892 100644 --- a/src/components/CippComponents/CippTranslations.jsx +++ b/src/components/CippComponents/CippTranslations.jsx @@ -29,7 +29,36 @@ export const CippTranslations = { mode: 'Mode', triggeredBy: 'Triggered By', baselineName: 'Baseline', + source: 'Synced From', + hasLocalChanges: 'Local Changes', secureScoreImpact: 'Secure Score Impact', + // Security Simulations + title: 'Title', + category: 'Category', + severity: 'Severity', + stepCount: 'Steps', + usesWhatIf: 'Live Sign-in Test', + standardsTotal: 'Standards', + standardsCompliant: 'In Place', + standardsGap: 'Gaps', + standardsUnknown: 'Not In A Baseline', + policyApplies: 'Applies', + result: 'Result', + controls: 'Controls', + operator: 'Operator', + group: 'Group', + persona: 'Persona', + identity: 'Evaluated As', + expected: 'Expected', + pass: 'Meets Expectation', + missingControl: 'Missing Control', + requiredControls: 'Required Controls', + blockedBy: 'Blocked By', + reportOnlyWouldStop: 'Report-only Would Stop It', + affectedPolicies: 'Affected Policies', + remediation: 'Remediation', + level: 'Level', + signal: 'Signal', userPrincipalName: 'User Principal Name', includeReasons: 'Included via', excludeReasons: 'Excluded via', @@ -143,4 +172,19 @@ export const CippTranslations = { ExecutedRequests: 'Executed', ServedRequests: 'Served (incl. cached)', EgressToday: 'Egress Today', + // Alert lifecycle + CmdletName: 'Alert', + ContentPreview: 'Item', + FirstSeen: 'First Seen', + LastSeen: 'Last Seen', + LastChecked: 'Last Checked', + ResolvedAt: 'Resolved', + ReopenCount: 'Reopened', + SnoozedBy: 'Snoozed By', + SnoozeReason: 'Snooze Reason', + RemainingDays: 'Remaining Days', + UntilResolved: 'Until Resolved', + KeepVisible: 'Kept Visible', + SnoozeUntilResolved: 'Snoozed Until Resolved', + SnoozeVisible: 'Kept Visible', } diff --git a/src/components/CippComponents/CippUserActions.jsx b/src/components/CippComponents/CippUserActions.jsx index b8d2e9d25dcc..6e89428556d4 100644 --- a/src/components/CippComponents/CippUserActions.jsx +++ b/src/components/CippComponents/CippUserActions.jsx @@ -166,7 +166,7 @@ const ManageLicensesForm = ({ formControl, tenant }) => { // Separate component for the Temporary Access Pass form so it can query the tenant's // TAP policy to validate the allowed lifetime range and enforce one-time use when forced -const TemporaryAccessPassForm = ({ formControl, row }) => { +export const TemporaryAccessPassForm = ({ formControl, row }) => { const tenantFilter = useSettings().currentTenant const rowData = Array.isArray(row) ? row[0] : row const tenant = tenantFilter === 'AllTenants' && rowData?.Tenant ? rowData.Tenant : tenantFilter @@ -283,6 +283,13 @@ const TemporaryAccessPassForm = ({ formControl, row }) => { dateTimeType="datetime" formControl={formControl} /> + ) } @@ -495,6 +502,39 @@ export const OutOfOfficeForm = ({ formControl, row }) => { ) } +// On-premises attributes Microsoft documents as clearable on cloud-only users once directory sync is gone: +// https://learn.microsoft.com/entra/identity/hybrid/connect/tshoot-clear-on-premises-attributes +const onPremAttributeOptions = [ + { + label: 'Immutable ID (onPremisesImmutableId)', + value: 'onPremisesImmutableId', + }, + { + label: 'Distinguished Name (onPremisesDistinguishedName)', + value: 'onPremisesDistinguishedName', + }, + { + label: 'Domain Name (onPremisesDomainName)', + value: 'onPremisesDomainName', + }, + { + label: 'SAM Account Name (onPremisesSamAccountName)', + value: 'onPremisesSamAccountName', + }, + { + label: 'Security Identifier (onPremisesSecurityIdentifier)', + value: 'onPremisesSecurityIdentifier', + }, + { + label: 'User Principal Name (onPremisesUserPrincipalName)', + value: 'onPremisesUserPrincipalName', + }, + { + label: 'Object Identifier (onPremisesObjectIdentifier)', + value: 'onPremisesObjectIdentifier', + }, +] + export const useCippUserActions = () => { const tenant = useSettings().currentTenant @@ -589,11 +629,25 @@ export const useCippUserActions = () => { label: 'Research Compromised Account', type: 'GET', icon: , - link: '/identity/administration/users/user/bec?userId=[id]', + link: '/identity/administration/bec/case?userId=[id]', confirmText: 'Are you sure you want to research if [userPrincipalName] is a compromised account?', multiPost: false, }, + { + // Queues one BEC run per selected user (bulk-capable); results land on + // the BEC Reports page + label: 'Run BEC investigation', + type: 'POST', + url: '/api/ExecBECBulkCheck', + icon: , + data: { UserIds: 'id' }, + multiPost: true, + bulkFilterEligible: true, + confirmText: + 'Queue a Business Email Compromise investigation for the selected users? Each run is kept; see the Business Email Compromise page under Identity.', + condition: (row) => row.userType !== 'Guest', + }, { //tested label: 'Create Temporary Access Pass', @@ -868,7 +922,7 @@ export const useCippUserActions = () => { userid: 'id', }, defaultvalues: { - destination: { label: 'OneDrive root', value: 'root' }, + destination: { label: 'Shortcuts folder (Microsoft UI)', value: 'shortcuts' }, }, fields: [ { @@ -1023,16 +1077,42 @@ export const useCippUserActions = () => { condition: () => canWriteUser, }, { - label: 'Clear Immutable ID', + label: 'Clear On-Premises Attributes', type: 'POST', icon: , - url: '/api/ExecClrImmId', + url: '/api/ExecClrOnPremAttributes', data: { ID: 'id', }, - confirmText: 'Are you sure you want to clear the Immutable ID for [userPrincipalName]?', + // Everything pre-selected: after a move to cloud-only the documented advice is to clear the whole set + defaultvalues: { Attributes: onPremAttributeOptions }, + fields: [ + { + type: 'autoComplete', + name: 'Attributes', + label: 'Attributes to clear', + multiple: true, + creatable: false, + options: onPremAttributeOptions, + validators: { required: 'Select at least one attribute' }, + }, + ], + confirmText: + 'Clear the selected on-premises attributes for [userPrincipalName]? Only cloud-only accounts can be updated. The previous values are written to the log.', multiPost: false, - condition: (row) => !row?.onPremisesSyncEnabled && row?.onPremisesImmutableId && canWriteUser, + // Cloud-only accounts that still carry something left over from directory sync + condition: (row) => + !row?.onPremisesSyncEnabled && + !!( + row?.onPremisesImmutableId || + row?.OnPremisesImmutableId || + row?.onPremisesDistinguishedName || + row?.onPremisesDomainName || + row?.onPremisesSamAccountName || + row?.onPremisesSecurityIdentifier || + row?.onPremisesUserPrincipalName + ) && + canWriteUser, }, { label: 'Set Source of Authority', diff --git a/src/components/CippComponents/EnrollmentProfileTabs.jsx b/src/components/CippComponents/EnrollmentProfileTabs.jsx index 65af01cc2d90..71111e1aee68 100644 --- a/src/components/CippComponents/EnrollmentProfileTabs.jsx +++ b/src/components/CippComponents/EnrollmentProfileTabs.jsx @@ -570,18 +570,14 @@ export const WindowsAutopilotEnrollmentProfiles = () => { title={`Windows Autopilot Profiles - ${currentTenant}`} queryKey={`AutopilotProfiles-${currentTenant}`} api={{ - url: '/api/ListGraphRequest', - data: { - tenantFilter: currentTenant, - Endpoint: 'deviceManagement/windowsAutopilotDeploymentProfiles', - $expand: 'assignments', - }, - dataKey: 'Results', + url: '/api/ListAutopilotConfig', + data: { tenantFilter: currentTenant, type: 'ApProfile' }, }} actions={autopilotActions} simpleColumns={[ 'displayName', 'description', + 'PolicyAssignment', 'language', 'extractHardwareHash', 'deviceNameTemplate', diff --git a/src/components/CippComponents/ScheduledTaskDetails.jsx b/src/components/CippComponents/ScheduledTaskDetails.jsx index 1713631ca796..6f72a9c1a5c3 100644 --- a/src/components/CippComponents/ScheduledTaskDetails.jsx +++ b/src/components/CippComponents/ScheduledTaskDetails.jsx @@ -156,7 +156,11 @@ const ScheduledTaskDetails = ({ data, showActions = true, showTitle = true }) => /> {deploymentId && (inFlight || progressRows.length > 0) && ( - + }> Progress @@ -207,6 +211,7 @@ const ScheduledTaskDetails = ({ data, showActions = true, showTitle = true }) => variant="outlined" expanded={expanded === "task-trigger"} onChange={handleChange("task-trigger")} + slotProps={{ transition: { unmountOnExit: true } }} > }> Trigger Configuration @@ -236,6 +241,7 @@ const ScheduledTaskDetails = ({ data, showActions = true, showTitle = true }) => variant="outlined" expanded={expanded === "task-parameters"} onChange={handleChange("task-parameters")} + slotProps={{ transition: { unmountOnExit: true } }} > }> Task Parameters @@ -317,65 +323,85 @@ const ScheduledTaskDetails = ({ data, showActions = true, showTitle = true }) => {filteredDetails && - filteredDetails.map((result, index) => ( - - } - sx={{ - "& .MuiAccordionSummary-content": { - display: "flex", - justifyContent: "space-between", - alignItems: "center", - width: "100%", - }, - }} + filteredDetails.map((result, index) => { + const panelId = `execution-results-${index}`; + const isExpanded = expanded === panelId; + return ( + - - {getCippFormatting(result.TenantName || result.Tenant, "Tenant")} - - } - sx={{ mx: 1 }} - /> - - - {result.Results === "null" || !result.Results ? ( - No data available - ) : Array.isArray(result.Results) ? ( - taskDetailResults.refetch()} - /> - ) : typeof result.Results === "object" ? ( - ({ - label: key, - value: typeof value === "object" ? JSON.stringify(value) : value, - }))} - /> - ) : ( - -
-                                {result.Results}
-                              
+ } + sx={{ + "& .MuiAccordionSummary-content": { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + width: "100%", + }, + }} + > + + {getCippFormatting(result.TenantName || result.Tenant, "Tenant")} - )} -
-
- ))} + } + sx={{ mx: 1 }} + /> +
+ {/* unmountOnExit keeps collapsed tenants' tables out of the tree. MUI mounts + Accordion content even when collapsed, so an AllTenants task otherwise + mounts one CippDataTable per tenant and locks the browser. */} + + {result.Results === "null" || !result.Results ? ( + + No data available + + ) : Array.isArray(result.Results) ? ( + taskDetailResults.refetch()} + /> + ) : typeof result.Results === "object" ? ( + ({ + label: key, + value: + typeof value === "object" + ? JSON.stringify(value) + : value, + }) + )} + /> + ) : ( + +
+                                  {result.Results}
+                                
+
+ )} +
+
+ ); + })} {filteredDetails && filteredDetails.length === 0 && ( { samAppPresent={me.data?.samAppPresent === true} completionButton={{ label: 'Enter CIPP', + color: 'success', onClick: () => { purgePersistedCache() // Refetching authmecipp makes PrivateRoute re-evaluate the gate; if a diff --git a/src/components/CippComponents/TenantInfoCard.jsx b/src/components/CippComponents/TenantInfoCard.jsx index 319c28052e2e..3c256d461294 100644 --- a/src/components/CippComponents/TenantInfoCard.jsx +++ b/src/components/CippComponents/TenantInfoCard.jsx @@ -1,16 +1,23 @@ -import { Box, Card, CardHeader, CardContent, Typography, Skeleton } from "@mui/material"; +import { Box, Card, CardHeader, CardContent, Link as MuiLink, Typography, Skeleton } from "@mui/material"; +import Link from "next/link"; import { CippIcons } from "../../utils/icon-registry"; import { CippCopyToClipBoard } from "./CippCopyToClipboard"; -export const TenantInfoCard = ({ data, isLoading }) => { +export const TenantInfoCard = ({ data, isLoading, tenantFilter }) => { return ( + Tenant - + } sx={{ pb: 1.5 }} /> diff --git a/src/components/CippFormPages/CippAddEditUser.jsx b/src/components/CippFormPages/CippAddEditUser.jsx index 58d6e30e361a..0b61c7574de7 100644 --- a/src/components/CippFormPages/CippAddEditUser.jsx +++ b/src/components/CippFormPages/CippAddEditUser.jsx @@ -40,6 +40,24 @@ const sharedMailboxApi = (tenantDomain) => ({ valueField: 'UPN', }) +const sharePointSiteRoleOptions = [ + { label: 'Members', value: 'Members' }, + { label: 'Owners', value: 'Owners' }, + { label: 'Visitors', value: 'Visitors' }, +] + +const sharePointSiteApi = (tenantDomain) => ({ + queryKey: `SharePointSites-${tenantDomain}`, + url: '/api/ListSites', + data: { type: 'SharePointSiteUsage' }, + labelField: (option) => `${option.displayName} (${option.webUrl})`, + valueField: 'webUrl', + addedField: { + rootWebTemplate: 'rootWebTemplate', + ownerPrincipalName: 'ownerPrincipalName', + }, +}) + const CippAddEditUser = (props) => { const { formControl, userSettingsDefaults, formType = 'add' } = props const tenantDomain = useSettings().currentTenant @@ -56,7 +74,6 @@ const CippAddEditUser = (props) => { const userTemplates = ApiGetCall({ url: `/api/ListNewUserDefaults?TenantFilter=${tenantDomain}`, queryKey: `UserDefaults-${tenantDomain}`, - refetchOnMount: false, refetchOnReconnect: false, enabled: formType === 'add', }) @@ -473,6 +490,7 @@ const CippAddEditUser = (props) => { ) applyField('jobTitle', template.jobTitle) + applyField('addedAliases', template.addedAliases) applyField('streetAddress', template.streetAddress) applyField('city', template.city) applyField('state', template.state) @@ -481,6 +499,7 @@ const CippAddEditUser = (props) => { applyField('companyName', template.companyName) applyField('department', template.department) applyField('mobilePhone', template.mobilePhone) + applyField('MustChangePass', template.MustChangePass === true, false) applyField('perUserMfa', template.perUserMfa === true, false) const templateBusinessPhone = Array.isArray(template.businessPhones) @@ -551,6 +570,12 @@ const CippAddEditUser = (props) => { )[0] ?? null, null ) + applyField('sharePointSites', toAutoCompleteOptions(template.sharePointSites), []) + applyField( + 'sharePointSiteRole', + toAutoCompleteOptions(template.sharePointSiteRole, sharePointSiteRoleOptions)[0] ?? null, + null + ) // Custom user attributes. On a switch, clear every known attribute field // first so attributes the new template doesn't define don't linger, then @@ -1171,6 +1196,30 @@ const CippAddEditUser = (props) => { formControl={formControl} />
+ + + + + + )} {formType === 'edit' && ( diff --git a/src/components/CippFormPages/CippFormPage.jsx b/src/components/CippFormPages/CippFormPage.jsx index ea65aab51c32..d8d95e46a9cd 100644 --- a/src/components/CippFormPages/CippFormPage.jsx +++ b/src/components/CippFormPages/CippFormPage.jsx @@ -43,6 +43,9 @@ const CippFormPage = (props) => { allowResubmit = false, addedButtons, onSubmitResult, + // (values) => [{ url, data }] posted after postUrl succeeds; their results render in the + // same results section as the primary submission + followUpRequests, ...other } = props const router = useRouter() @@ -95,6 +98,8 @@ const CippFormPage = (props) => { if (!isValid) { return } + // built before removeEmpty below, which mutates the values it is handed + const followUps = followUpRequests?.(formControl.getValues()) const values = customDataformatter ? customDataformatter(formControl.getValues()) : formControl.getValues() @@ -125,6 +130,7 @@ const CippFormPage = (props) => { postCall.mutate({ url: postUrl, data: values, + followUps, }) } const formPageActions = { diff --git a/src/components/CippFormPages/CippJSONView.jsx b/src/components/CippFormPages/CippJSONView.jsx index d0657c502ebd..6279f5530eca 100644 --- a/src/components/CippFormPages/CippJSONView.jsx +++ b/src/components/CippFormPages/CippJSONView.jsx @@ -602,7 +602,11 @@ function CippJsonView({ } } - return getPresentationTypeLabel(presentationValue?.['@odata.type']) + // The label the template recorded at capture, for a presentation the current tenant cannot resolve. + return ( + presentationValue?.presentation?.label || + getPresentationTypeLabel(presentationValue?.['@odata.type']) + ) } const resolveLivePresentationLabel = (_definition, presentationValue, presentationIndex) => @@ -782,7 +786,10 @@ function CippJsonView({ addedValue?.['definition@odata.bind'], definitionBindPattern ) - const definition = definitionId ? addedDefinitionsMap[definitionId] : null + // Templates record each setting's identity next to the bind: imported ADMX definitions carry + // a different id in every tenant, so the lookup above only resolves ids of the tenant viewed. + const definition = + (definitionId ? addedDefinitionsMap[definitionId] : null) || addedValue?.definition || null addAdministrativeTemplateValue(addedValue, index, { definition, definitionId, @@ -874,6 +881,10 @@ function CippJsonView({ if (!Array.isArray(arr) || arr.length === 0) return false return arr.every((item) => { if (typeof item !== 'object' || item === null || Array.isArray(item)) return false + // Only genuine {key, value} pairs flatten. A flat record without them (a phone number + // assignment, an Intune template) would otherwise collapse to an empty pane. + if (!(item.key || item.name || item.displayName)) return false + if (!('value' in item || 'newValue' in item)) return false // Check if all values are primitives (not nested objects/arrays) return Object.values(item).every((val) => typeof val !== 'object' || val === null) }) diff --git a/src/components/CippIntegrations/CippApiClientManagement.jsx b/src/components/CippIntegrations/CippApiClientManagement.jsx index 3e06fe1f990a..ba66053a8e05 100644 --- a/src/components/CippIntegrations/CippApiClientManagement.jsx +++ b/src/components/CippIntegrations/CippApiClientManagement.jsx @@ -1,4 +1,4 @@ -import { Button, Stack, SvgIcon, Menu, MenuItem, ListItemText, Alert, Tooltip } from "@mui/material"; +import { Button, Stack, SvgIcon, Menu, MenuItem, ListItemText, Alert } from "@mui/material"; import { CippIcons } from "../../utils/icon-registry" import { useState, useEffect, useMemo } from "react"; import isEqual from "lodash/isEqual"; @@ -23,27 +23,45 @@ const getRestrictiveRoleRanges = (role) => { return ranges.filter((range) => !ALLOW_ALL_IP_TOKENS.has(String(range).trim().toLowerCase())); }; -// Dialog warning: MCP runs under the signed-in user's role, so an IP-restricted role blocks the -// AI provider's cloud egress IPs (403). +// Restrictive entries from a free-form IP-range list (client's own IPRange field or similar). +const getRestrictiveRanges = (ranges) => + (Array.isArray(ranges) ? ranges : []) + .map((r) => String(r?.value ?? r).trim()) + .filter((r) => r && !ALLOW_ALL_IP_TOKENS.has(r.toLowerCase())); + +// Dialog warning (advisory, not enforced): MCP connectors call in from the AI provider's cloud IPs, +// so an IP restriction on the client itself OR on its role will most likely block them (403). const McpRoleIpWarning = ({ formControl }) => { const mcpAllowed = useWatch({ control: formControl.control, name: "MCPAllowed" }); const roleValue = useWatch({ control: formControl.control, name: "Role" }); + const ipRangeValue = useWatch({ control: formControl.control, name: "IPRange" }); const customRoles = ApiGetCall({ url: "/api/ListCustomRole", queryKey: "CustomRoleList" }); + if (!mcpAllowed) return null; + + const clientRanges = getRestrictiveRanges(ipRangeValue); const roleName = roleValue?.value ?? roleValue; - if (!mcpAllowed || !roleName) return null; + const role = roleName + ? (customRoles.data ?? []).find( + (r) => String(r.RoleName).toLowerCase() === String(roleName).toLowerCase() + ) + : null; + const roleRanges = getRestrictiveRoleRanges(role); - const role = (customRoles.data ?? []).find( - (r) => String(r.RoleName).toLowerCase() === String(roleName).toLowerCase() - ); - const restrictive = getRestrictiveRoleRanges(role); - if (restrictive.length === 0) return null; + if (clientRanges.length === 0 && roleRanges.length === 0) return null; return ( - Role {roleName} only allows {restrictive.join(", ")}. MCP runs as the - signed-in user, so AI clients connecting from their provider's cloud IPs get blocked (403). - Add that range to the role or clear its IP restriction. + MCP connectors call in from your AI provider's cloud IPs, so IP restrictions on an MCP client + will most likely block it (403). + {clientRanges.length > 0 && <> This client's IP range only allows {clientRanges.join(", ")}.} + {roleRanges.length > 0 && ( + <> + {" "} + Role {roleName} only allows {roleRanges.join(", ")}. + + )}{" "} + Consider setting the IP range to Any and using a role with no IP restriction. ); }; @@ -82,6 +100,7 @@ const CippApiClientManagement = () => { queryKey: "CustomRoleList", }); + // Authoritative per-client egress (today) from Craft's accounting table. Self-hides (Enabled:false) // when accounting is off / not hosted, in which case the column shows "-". const egressUsage = ApiGetCall({ @@ -111,21 +130,23 @@ const CippApiClientManagement = () => { }); }, [apiClients.data, egressUsage.data]); - // MCP-enabled clients whose role restricts sign-in to specific IPs. Those restrictions apply to - // MCP traffic (which runs as the signed-in user), so an AI client's cloud egress IPs get blocked. + // MCP-enabled clients with an IP restriction — on the client's own IP range or on its role. MCP + // connectors call in from the AI provider's cloud IPs, so either will most likely block them (403). const mcpRoleIpWarnings = useMemo(() => { if (!apiClients.isSuccess || !customRoles.isSuccess) return []; const roles = customRoles.data ?? []; const clients = apiClients.data?.pages?.[0]?.Results || []; return clients - .filter((client) => client.MCPAllowed && client.Role) + .filter((client) => client.MCPAllowed) .map((client) => { - const role = roles.find( - (r) => String(r.RoleName).toLowerCase() === String(client.Role).toLowerCase() - ); - const restrictive = getRestrictiveRoleRanges(role); - return restrictive.length > 0 - ? { appName: client.AppName, role: client.Role, ranges: restrictive } + const role = client.Role + ? roles.find((r) => String(r.RoleName).toLowerCase() === String(client.Role).toLowerCase()) + : null; + const roleRanges = getRestrictiveRoleRanges(role); + const clientRanges = getRestrictiveRanges(client.IPRange); + const ranges = [...new Set([...clientRanges, ...roleRanges])]; + return ranges.length > 0 + ? { appName: client.AppName, role: client.Role, ranges } : null; }) .filter(Boolean); @@ -273,7 +294,7 @@ const CippApiClientManagement = () => { name: "mcpAccessWarning", severity: "warning", label: - "Enabling MCP Access converts this client into the MCP resource app — it can no longer be used as a normal API client, and only one client per tenant can hold this role. Going forward, MCP is only supported on CIPP-NG.", + "Enabling MCP Access sets this client up as an MCP connector sign-in app — AI clients (Claude, ChatGPT, Copilot Studio, VS Code) sign in as it, and the shared CIPP-MCP resource app is created automatically. You can enable multiple MCP clients, each with its own role, IP range and Conditional Access. MCP is only supported on CIPP-NG.", }, { name: "mcpRoleIpWarning", @@ -407,22 +428,6 @@ const CippApiClientManagement = () => { "Not Available" ), }, - { - label: "MCP API URL", - value: azureConfig.data?.Results?.ApiUrl ? ( - <> - - - - - - ) : ( - "Not Available" - ), - }, { label: "Token URL", value: azureConfig.data?.Results?.TenantID ? ( @@ -471,14 +476,14 @@ const CippApiClientManagement = () => { {mcpRoleIpWarnings.length > 0 && ( - These MCP-enabled clients use an IP-restricted role. MCP runs as the signed-in user, so - AI clients connecting from their provider's cloud IPs get blocked (403). Add that range - to the role or clear its IP restriction: + These MCP-enabled clients have an IP restriction (on the client or its role). MCP + connectors call in from your AI provider's cloud IPs, so this will most likely block + them (403). Consider setting the IP range to Any and using a role with no IP + restriction:
    {mcpRoleIpWarnings.map((warning) => (
  • - {warning.appName} — {warning.role} allows only{" "} - {warning.ranges.join(", ")} + {warning.appName} — allows only {warning.ranges.join(", ")}
  • ))}
@@ -506,7 +511,15 @@ const CippApiClientManagement = () => { "IPRange", "EgressToday", ]} - queryKey={`ApiClients`} + // Distinct from the page's "ApiClients" data query. This table is fed the merged + // clientRows via the `data` prop, but CippDataTable still spins up an internal + // ApiGetCallWithPagination keyed on this queryKey. ApiGetCall(WithPagination) keys + // react-query on [queryKey] alone (no url), so reusing "ApiClients" here made the + // internal query (url undefined) share the page query's cache entry and, being the + // last-rendered observer, hijack its queryFn — on invalidation the list refetched to + // empty ("No records") until a manual refresh. A separate key avoids the collision; + // the list still refreshes via relatedQueryKeys → the page's apiClients → clientRows. + queryKey={`ApiClientsTable`} /> @@ -569,7 +582,7 @@ const CippApiClientManagement = () => { name: "mcpAccessWarning", severity: "warning", label: - "Enabling MCP Access converts this client into the MCP resource app — it can no longer be used as a normal API client, and only one client per tenant can hold this role. Going forward, MCP is only supported on CIPP-NG.", + "Enabling MCP Access sets this client up as an MCP connector sign-in app — AI clients (Claude, ChatGPT, Copilot Studio, VS Code) sign in as it, and the shared CIPP-MCP resource app is created automatically. You can enable multiple MCP clients, each with its own role, IP range and Conditional Access. MCP is only supported on CIPP-NG.", }, { name: "mcpRoleIpWarning", @@ -654,7 +667,7 @@ const CippApiClientManagement = () => { name: "mcpAccessWarning", severity: "warning", label: - "Enabling MCP Access converts this client into the MCP resource app — it can no longer be used as a normal API client, and only one client per tenant can hold this role. Going forward, MCP is only supported on CIPP-NG.", + "Enabling MCP Access sets this client up as an MCP connector sign-in app — AI clients (Claude, ChatGPT, Copilot Studio, VS Code) sign in as it, and the shared CIPP-MCP resource app is created automatically. You can enable multiple MCP clients, each with its own role, IP range and Conditional Access. MCP is only supported on CIPP-NG.", }, { name: "mcpRoleIpWarning", diff --git a/src/components/CippIntegrations/CippIntegrationFieldMapping.jsx b/src/components/CippIntegrations/CippIntegrationFieldMapping.jsx index 32e59e6f6548..90e07bc17c6e 100644 --- a/src/components/CippIntegrations/CippIntegrationFieldMapping.jsx +++ b/src/components/CippIntegrations/CippIntegrationFieldMapping.jsx @@ -124,7 +124,7 @@ const CippIntegrationFieldMapping = () => { label={field.FieldLabel} options={fieldMapping?.data?.IntegrationFields?.filter( (integrationField) => - (integrationField?.type === field.Type && + ([].concat(field.Type).includes(integrationField?.type) && integrationField?.FieldType === field.FieldType) || integrationField?.type === "unset" )?.map((integrationField) => { diff --git a/src/components/CippIntegrations/CippIntegrationTenantMapping.jsx b/src/components/CippIntegrations/CippIntegrationTenantMapping.jsx index b94a839fb7db..ef283c2103e8 100644 --- a/src/components/CippIntegrations/CippIntegrationTenantMapping.jsx +++ b/src/components/CippIntegrations/CippIntegrationTenantMapping.jsx @@ -185,17 +185,26 @@ const CippIntegrationSettings = ({ children }) => { }); }; + const extension = extensions.find((extension) => extension.id === router.query.id); + + // Only these extensions support syncing a single tenant through ExecExtensionSync. + const tenantSyncExtensions = ["NinjaOne", "Hudu"]; + const actions = [ - { - label: "Sync Now", - icon: ( - - - - ), - confirmText: "Queue a NinjaOne sync for [Tenant]?", - customFunction: handleSyncTenant, - }, + ...(tenantSyncExtensions.includes(extension?.id) + ? [ + { + label: "Sync Now", + icon: ( + + + + ), + confirmText: `Queue a ${extension.name} sync for [Tenant]?`, + customFunction: handleSyncTenant, + }, + ] + : []), { label: "Delete Mapping", icon: , @@ -204,8 +213,6 @@ const CippIntegrationSettings = ({ children }) => { }, ]; - const extension = extensions.find((extension) => extension.id === router.query.id); - // Memoize the removeOptions array to ensure it updates when tableData changes const removedTenantIds = useMemo(() => { return Array.isArray(tableData) ? tableData.map((item) => item.TenantId) : []; diff --git a/src/components/CippIntegrations/CippMcpManagement.jsx b/src/components/CippIntegrations/CippMcpManagement.jsx new file mode 100644 index 000000000000..69b00e66fcee --- /dev/null +++ b/src/components/CippIntegrations/CippMcpManagement.jsx @@ -0,0 +1,250 @@ +import { + Alert, + Button, + Card, + CardContent, + CardHeader, + Divider, + Stack, + Typography, +} from "@mui/material"; +import { Box } from "@mui/system"; +import { useEffect, useMemo } from "react"; +import { useForm } from "react-hook-form"; +import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; +import { CippApiResults } from "../CippComponents/CippApiResults"; +import { CippCopyToClipBoard } from "../CippComponents/CippCopyToClipboard"; +import { CippFormComponent } from "../CippComponents/CippFormComponent"; +import { CippPropertyListCard } from "../CippCards/CippPropertyListCard"; + +// One self-contained section per MCP-enabled client: its connector (sign-in) URL, its pre-auth +// status, and its own redirect-URI boxes. Each client has its own form + save so multiple MCP +// clients never share state or overwrite each other's callbacks. +const McpClientCard = ({ client, baseMcpUrl }) => { + const form = useForm({ + mode: "onChange", + defaultValues: { + PublicRedirectUris: client.PublicRedirectUris ?? [], + WebRedirectUris: client.WebRedirectUris ?? [], + }, + }); + const post = ApiPostCall({ relatedQueryKeys: ["McpAuthStatus"] }); + + // Re-sync when the server returns updated URIs for this client (e.g. after a save). + useEffect(() => { + form.reset({ + PublicRedirectUris: client.PublicRedirectUris ?? [], + WebRedirectUris: client.WebRedirectUris ?? [], + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [client.PublicRedirectUris, client.WebRedirectUris]); + + return ( + + + + + + {baseMcpUrl && ( + + + Connector URL + + + + )} + {client.UserImpersonationPreAuthorized === false && ( + + This client isn't pre-authorized on the CIPP-MCP resource scope yet, so users may + hit a consent prompt on first connect. Run Actions > Save to Azure{" "} + on the API Clients tab to apply it (it also self-heals on the next warmup). + + )} + + + + + + + + + + ); +}; + +// MCP connector management: the shared CIPP-MCP resource app (the token audience CIPP creates and +// manages) plus a section per MCPAllowed client app. Kept on its own tab so it doesn't clutter API +// client management. Enabling MCP on a client, and its role/IP, live with the client on the API +// Clients tab; here you check the resource app and each client's connector URL and redirect URIs. +const CippMcpManagement = () => { + // Shared query keys with the API Clients tab — react-query dedupes, so no extra network calls. + const azureConfig = ApiGetCall({ + url: "/api/ExecApiClient", + data: { Action: "GetAzureConfiguration" }, + queryKey: "AzureConfiguration", + }); + const mcpAuth = ApiGetCall({ + url: "/api/ExecApiClient", + data: { Action: "GetMcpAuth" }, + queryKey: "McpAuthStatus", + }); + + const mcpClients = useMemo(() => mcpAuth.data?.Results?.Clients ?? [], [mcpAuth.data]); + const results = mcpAuth.data?.Results; + const baseMcpUrl = azureConfig.data?.Results?.ApiUrl + ? `${azureConfig.data.Results.ApiUrl.replace(/\/+$/, "")}/api/ExecMcp` + : null; + + const noMcpClients = mcpAuth.isSuccess && mcpClients.length === 0; + + return ( + + {noMcpClients && ( + + No MCP clients yet. On the API Clients tab, create or edit an API client + and turn on MCP Access Allowed, then run Actions > Save to + Azure. CIPP creates the shared CIPP-MCP resource app and wires the client + automatically. + + )} + {mcpAuth.isSuccess && results?.ResourceConflict && ( + + Open in Entra + + ) : null + } + > + MCP setup is blocked: the app registration{" "} + {results.ResourceConflict.AppName || results.ResourceConflict.AppId} is + already using the MCP resource URL. This is usually a client left over from the previous + single-app MCP setup. Delete that app registration in Entra (and remove it from{" "} + API Clients if it's listed there), then run{" "} + Actions > Save to Azure again. + + )} + {mcpAuth.isSuccess && + mcpClients.length > 0 && + results?.ResourceConfigured === false && + !results?.ResourceConflict && ( + + The CIPP-MCP resource app isn't set up yet — it's the shared, + CIPP-managed app that MCP tokens are issued for. Click Actions > Save to + Azure on the API Clients tab to create it. Until then MCP connectors can't + sign in. + + )} + + + ) : ( + "Not configured" + ), + }, + { + label: "Object ID", + value: results?.ResourceObjectId ? ( + + ) : ( + "-" + ), + }, + { + label: "Managed callbacks (mobile & desktop)", + value: (results?.DefaultRedirectUris || []).join(", ") || "None", + }, + { + label: "Managed callbacks (web / confidential)", + value: (results?.DefaultWebRedirectUris || []).join(", ") || "None", + }, + { + label: "Identifier URIs", + value: (results?.ResourceIdentifierUris || []).join(", ") || "-", + }, + { + label: "Manage in Entra", + value: results?.ResourceAppId ? ( + + ) : ( + "-" + ), + }, + ]} + /> + + {mcpAuth.isSuccess && + mcpClients.map((client) => ( + + ))} + + ); +}; + +export default CippMcpManagement; diff --git a/src/components/CippPdf/CippBrandingReportPreview.jsx b/src/components/CippPdf/CippBrandingReportPreview.jsx index 70f05e118e5b..e1390e53cf2a 100644 --- a/src/components/CippPdf/CippBrandingReportPreview.jsx +++ b/src/components/CippPdf/CippBrandingReportPreview.jsx @@ -1,122 +1,42 @@ -import { useMemo } from 'react' -import { CippPdfPreview } from './CippPdfPreview' -import { ExecutiveReportDocument } from '../ExecutiveReportButton' -import { ShadowAIReportDocument } from '../ShadowAIReportButton' -import { BECRemediationReportDocument } from '../BECRemediationReportButton' -import { SharingReportDocument } from './SharingReportButton' -import { PermissionsReportDocument } from './PermissionsReportButton' -import { MailFlowReportDocument } from './MailFlowReportButton' -import { ReportBuilderDocument } from '../ReportBuilder/ReportBuilderPDF' -import { SAMPLE_DATA_BY_REPORT, SAMPLE_TENANT_NAME } from './previewSampleData' -import { useReportVariables } from './useReportVariables' - -// Renders a real report — the same document a client would receive — against sample data, so the -// branding being edited can be checked on every page rather than on a mock of the cover. -// -// The sample data comes from previewSampleData, which nothing else may import. A report that has no -// real data still renders its empty states; this is the one place invented numbers are allowed. - -/** - * Every section on, so the preview shows the fullest version of each report. Whichever sections an - * operator later turns off, the branding they are choosing here still has to work. - * - * `securityStandards` is the one exception, and it is not a choice made here: the executive report - * renders the standards page only when drift compliance is *off*, and the drift pages only when - * standards is off (see the guards in ExecutiveReportButton). The two are alternative views of the - * same data, so no single document can contain both. Drift is the one shown because it is three - * pages to the standards page's one, and exercises more of the design — the donut, the stat row, - * the deviations table and the grouped bullet lists. - */ -const EXECUTIVE_SECTIONS = { - executiveSummary: true, - securityStandards: false, - driftCompliance: true, - secureScore: true, - licenseManagement: true, - deviceManagement: true, - conditionalAccess: true, - infographics: true, - shadowAI: true, -} - -/** - * Build the report document for a given type. Pure, and exported so it can be rendered to a real - * PDF in tests without mounting the viewer. - */ -export const buildPreviewDocument = (reportType, brandingSettings, variables) => { - const sample = SAMPLE_DATA_BY_REPORT[reportType] ?? SAMPLE_DATA_BY_REPORT.executive - const shared = { brandingSettings, tenantName: SAMPLE_TENANT_NAME, variables } - - switch (reportType) { - case 'reportBuilder': - return ( - - ) - case 'shadowAI': - return - case 'bec': - return ( - - ) - case 'sharing': - return - case 'permissions': - return - case 'mailFlow': - return - default: - return ( - - ) - } +import { useEffect, useMemo, useState } from 'react' +import { useSettings } from '../../hooks/use-settings' +import { ServerPdfPane, useServerPdf } from './useServerPdf' + +// Renders a real report server-side - the same document a client would receive - against sample +// data, so the branding being edited can be checked on every page rather than on a mock of the +// cover. The sample data lives beside the renderer (Config/ReportSampleData.json). + +// A colour picker drag changes the branding many times a second; the render is asked for once the +// values have settled. +const useSettled = (value, ms) => { + const [settled, setSettled] = useState(value) + useEffect(() => { + const timer = setTimeout(() => setSettled(value), ms) + return () => clearTimeout(timer) + }, [value, ms]) + return settled } -const CippBrandingReportPreview = ({ reportType = 'executive', brandingSettings }) => { - // Resolved against the selected tenant, so a footer being written with %cippurl% or a custom - // variable previews the value it will actually print rather than the token. - const variables = useReportVariables() - - // Rebuilding the document on every keystroke of a colour picker would re-run the whole PDF - // layout; keying on the branding values that actually reach the page keeps that to real changes. - const document = useMemo( - () => buildPreviewDocument(reportType, brandingSettings, variables), - // eslint-disable-next-line react-hooks/exhaustive-deps -- rebuild only on values the PDF reads - [ - reportType, - variables, - brandingSettings?.colour, - brandingSettings?.secondaryColour, - brandingSettings?.logo, - brandingSettings?.coverImage, - brandingSettings?.coverImageId, - brandingSettings?.coverStock, - brandingSettings?.footerText, - brandingSettings?.coverFooterText, - brandingSettings?.showFooter, - brandingSettings?.showPageNumbers, - brandingSettings?.watermarkText, - brandingSettings?.watermarkEnabled, - ] +const CippBrandingReportPreview = ({ + reportType = 'executive', + brandingSettings, +}) => { + // Resolved against the selected tenant, so a footer written with %cippurl% or a custom variable + // previews the value it will print rather than the token. + const tenantFilter = useSettings().currentTenant + const requestKey = useSettled( + JSON.stringify({ reportType, branding: brandingSettings, tenantFilter }), + 600 ) + const body = useMemo(() => JSON.parse(requestKey), [requestKey]) + const pdf = useServerPdf({ url: '/api/ExecPreviewBrandingReportPdf', body }) return ( - - {document} - + errorText="The preview could not be rendered." + /> ) } diff --git a/src/components/CippPdf/LicenseReportButton.jsx b/src/components/CippPdf/LicenseReportButton.jsx new file mode 100644 index 000000000000..0c401d7c8471 --- /dev/null +++ b/src/components/CippPdf/LicenseReportButton.jsx @@ -0,0 +1,225 @@ +import { useState } from 'react' +import { CippIcons } from '../../utils/icon-registry' +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Paper, + Stack, + Switch, + Tooltip, + Typography, +} from '@mui/material' +import { useSettings } from '../../hooks/use-settings' +import { useBrandingSettings } from './useBrandingSettings' +import { ServerPdfPane, useServerPdf } from './useServerPdf' + +// Which sections the report can carry. `overview` is the page that makes it a report, so it is +// always on. +export const LICENSE_REPORT_SECTIONS = [ + { + key: 'spend', + label: 'What you pay for', + description: 'Every plan, seats owned versus in use, and monthly cost.', + }, + { + key: 'reclaim', + label: 'Licenses you can remove', + description: 'Unassigned, switched-off, inactive and duplicate licenses.', + }, + { + key: 'downgrades', + label: 'Cheaper plans', + description: 'People whose plan includes more than they use.', + }, + { + key: 'upgrades', + label: 'Better plans', + description: + 'Bundles that cost less, and people with no security protection.', + }, + { + key: 'terms', + label: 'Yearly or monthly', + description: 'How many seats are stable enough to commit to for a year.', + }, + { + key: 'method', + label: 'How this was measured', + description: 'Sources, time window and assumptions.', + }, +] + +export const DEFAULT_LICENSE_REPORT_SECTIONS = Object.fromEntries( + LICENSE_REPORT_SECTIONS.map((section) => [section.key, true]) +) + +// The report PDF is rendered server-side (ExecGetLicenseReportPdf) by the shared CIPPSharp kit, +// which re-runs the page's license analysis with the same `settings` the page's table uses; the +// dialog previews the returned PDF and re-renders it when a section is switched. +export const LicenseReportButton = ({ + report, + tenantName, + settings, + disabled = false, +}) => { + const [dialogOpen, setDialogOpen] = useState(false) + const [sections, setSections] = useState(DEFAULT_LICENSE_REPORT_SECTIONS) + const tenantFilter = useSettings().currentTenant + const branding = useBrandingSettings() + const hasData = !!report?.Summary && report.Summary.DataAvailable !== false + const pdf = useServerPdf({ + url: '/api/ExecGetLicenseReportPdf', + body: { + tenantFilter, + ...settings, + sections, + brandingPresetId: branding?.reportDefaults?.licensing ?? '', + }, + enabled: dialogOpen, + }) + + const toggleSection = (key) => + setSections((current) => ({ ...current, [key]: !current[key] })) + + const safeTenant = (tenantName || 'tenant').replace(/[^a-z0-9.-]/gi, '_') + const fileName = `Licensing_Report_${safeTenant}_${new Date().toISOString().split('T')[0]}.pdf` + + return ( + <> + + + + + + + setDialogOpen(false)} + maxWidth="xl" + fullWidth + slotProps={{ + paper: { sx: { height: '90vh' } }, + }} + > + + + + Licensing Report - {tenantName} + + setDialogOpen(false)} size="small"> + + + + + + + + Report Sections + + + The summary page is always included. Recommendation sections + follow the switches on the page; a section switched off there has + nothing to show here. + + + {LICENSE_REPORT_SECTIONS.map((option) => ( + toggleSection(option.key)} + sx={{ + p: 1.5, + border: '1px solid', + borderColor: sections[option.key] + ? 'primary.main' + : 'divider', + bgcolor: sections[option.key] + ? 'primary.50' + : 'background.paper', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + }} + > + { + event.stopPropagation() + toggleSection(option.key) + }} + onClick={(event) => event.stopPropagation()} + color="primary" + size="small" + /> + + + {option.label} + + + {option.description} + + + + ))} + + + + + + + + + + + + + ) +} + +export default LicenseReportButton diff --git a/src/components/CippPdf/MailFlowReportButton.jsx b/src/components/CippPdf/MailFlowReportButton.jsx index 83576f9177b4..065057ed4828 100644 --- a/src/components/CippPdf/MailFlowReportButton.jsx +++ b/src/components/CippPdf/MailFlowReportButton.jsx @@ -3,7 +3,6 @@ import { CippIcons } from '../../utils/icon-registry' import { Box, Button, - CircularProgress, Dialog, DialogActions, DialogContent, @@ -12,369 +11,24 @@ import { Tooltip, Typography, } from '@mui/material' -import { PDFDownloadLink } from '@react-pdf/renderer' -import { CippPdfPreview } from './CippPdfPreview' -import { - AlertBox, - Bold, - BulletList, - ClearBox, - ContentPage, - CoverMeta, - DataTable, - DonutChart, - InfoBox, - Paragraph, - REPORT_COLOURS, - ReportDocument, - Section, - StatRow, - TrendChart, - severityColour, -} from './index' -import { useReportVariables } from './useReportVariables' -import { useBrandingSettings } from './useBrandingSettings' - -const nz = (value) => Number(value ?? 0) -const num = (value) => nz(value).toLocaleString() -const pct = (part, whole) => (whole > 0 ? Math.round((part / whole) * 1000) / 10 : 0) - -// Get-MailFlowStatusReport event types, in the order they read as a funnel: delivered mail first, -// then the things that stopped mail, worst last. -const DISPOSITIONS = [ - { key: 'GoodMail', label: 'Good mail' }, - { key: 'TransportRules', label: 'Transport rules' }, - { key: 'SpamDetections', label: 'Spam' }, - { key: 'EdgeBlockSpam', label: 'Edge blocked spam' }, - { key: 'EmailPhish', label: 'Phish' }, - { key: 'EmailMalware', label: 'Malware' }, -] - -/** - * Grades mail hygiene from the share of the window's mail that each threat class accounts for. - * - * Shares rather than counts, because a raw count of blocked phish means nothing without the volume - * it was drawn from. Malware and phish are weighted far harder than spam: spam is nuisance traffic - * every tenant carries, while a targeted-payload rate above roughly one percent of all mail means - * the organisation is being actively worked on rather than incidentally scraped. - */ -const assessHygiene = (totals, totalMail) => { - if (totalMail <= 0) return { level: 'Good', severity: 'low' } - const targeted = pct(nz(totals.EmailMalware) + nz(totals.EmailPhish), totalMail) - const spam = pct(nz(totals.SpamDetections) + nz(totals.EdgeBlockSpam), totalMail) - - if (targeted > 1 || spam > 25) return { level: 'Attention Needed', severity: 'high' } - if (targeted > 0.25 || spam > 10) return { level: 'Fair', severity: 'medium' } - return { level: 'Good', severity: 'low' } -} - -// Exported so the branding preview can render this report against sample data, and so tests can -// render it to a real PDF. -export const MailFlowReportDocument = ({ - mailFlowData, - brandingSettings, - tenantName, - generatedOn, - variables, -}) => { - const days = nz(mailFlowData?.days) || 14 - const totals = mailFlowData?.totals ?? {} - const directionTotals = mailFlowData?.directionTotals ?? {} - const daily = mailFlowData?.daily ?? [] - const topSenders = mailFlowData?.topSenders ?? [] - const topSpamRecipients = mailFlowData?.topSpamRecipients ?? [] - - const totalMail = DISPOSITIONS.reduce((sum, item) => sum + nz(totals[item.key]), 0) - const goodMailPct = pct(nz(totals.GoodMail), totalMail) - const phish = nz(totals.EmailPhish) - const malware = nz(totals.EmailMalware) - const threats = phish + malware - const transportRules = nz(totals.TransportRules) - - const hygiene = assessHygiene(totals, totalMail) - const hygieneColour = severityColour(hygiene.severity) - - const dayLabel = (value) => - value ? new Date(value).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '' - - const volumeSeries = daily.map((row) => ({ - label: dayLabel(row.date), - value: DISPOSITIONS.reduce((sum, item) => sum + nz(row[item.key]), 0), - })) - - const directionSeries = [ - { label: 'Inbound', value: nz(directionTotals.Inbound) }, - { label: 'Outbound', value: nz(directionTotals.Outbound) }, - { label: 'Intra-org', value: nz(directionTotals.IntraOrg) }, - ] - - // Numbered after filtering, so a report that drops a conditional action still counts from one. - const priorityActions = [ - threats > 0 && { - label: 'Review anti-phishing and anti-malware policy strength.', - text: `${num(threats)} messages were blocked as phishing or malware in this window. Confirm the tenant is on the current preset security policies, that impersonation protection lists the people who would actually be impersonated, and that Safe Links and Safe Attachments cover every mailbox rather than a pilot group.`, - }, - topSpamRecipients.length > 0 && { - label: 'Give the most-targeted users stronger protection.', - text: 'The recipients listed in this report absorb a disproportionate share of unwanted mail. Priority accounts, tighter quarantine policy and a short conversation about what they are receiving cost little and are aimed exactly where the traffic is going.', - }, - { - label: 'Verify SPF, DKIM and DMARC are published and enforcing.', - text: 'These records decide whether mail claiming to be from the domain is accepted elsewhere. A DMARC policy left at p=none reports abuse without stopping it, which means the organisation can be impersonated to its own customers regardless of how well inbound filtering performs.', - }, - transportRules > 0 && { - label: 'Audit the transport rules acting on mail.', - text: `Transport rules handled ${num(transportRules)} messages here. Rules accumulate, outlive the reason they were written, and silently override filtering decisions — confirm each one is still wanted and that none bypasses protection for a sender that no longer needs the exception.`, - }, - ] - .filter(Boolean) - .map((item, index) => ({ ...item, marker: `${index + 1}.` })) - - const dispositionRows = DISPOSITIONS.map((item) => ({ - disposition: item.label, - messages: num(totals[item.key]), - share: `${pct(nz(totals[item.key]), totalMail)}%`, - })) - - return ( - - } - > - {/* EXECUTIVE SUMMARY */} - -
- - Every message entering or leaving the organisation is given a disposition — delivered, - held by a transport rule, filtered as spam, or blocked as phishing or malware. Those - dispositions are the clearest single measure of what the mail environment is being asked - to handle, because they count what the filters actually did rather than what they are - configured to do. This report covers the last {days} days of mail flow at{' '} - {tenantName}. - - - 0 ? REPORT_COLOURS.warning : undefined, - }, - { - value: num(malware), - label: 'Malware Blocked', - colour: malware > 0 ? REPORT_COLOURS.danger : undefined, - }, - ]} - /> - - - {hygiene.level === 'Attention Needed' && - 'Threat traffic is a material share of total mail. At this rate the organisation is being targeted rather than incidentally caught by bulk campaigns, and the filters are absorbing volume that protection policy and user awareness should be reducing at source. Treat the recommendations as current work.'} - {hygiene.level === 'Fair' && - 'Threats are being caught at a level that is normal for an organisation of this profile, but not negligible. The filtering is working; the value now is in checking which users absorb most of it and whether their protection matches their exposure.'} - {hygiene.level === 'Good' && - 'Threat traffic is a small fraction of total mail and is being stopped before delivery. Nothing here needs action beyond keeping the review cadence, since a change in this profile is usually the first visible sign of a campaign starting.'} - -
- -
- - Figures come from Microsoft's mail flow status report for the tenant, aggregated as daily - counts per disposition and direction. It is a count of messages, not a record of them: - individual senders, subjects and recipients are not part of this data set, and a message - appears once under the disposition that was applied to it. - - - A blocked message is a filter working, not an incident. Nothing here indicates that a - threat reached a user or that an account was compromised — that requires message trace - and sign-in data, which are reviewed separately. Equally, a clean result does not prove - nothing got through; it proves nothing was recognised. - -
-
- - {/* VOLUME & DISPOSITIONS */} - -
- - Total messages handled per day across every disposition. Steady volume with occasional - peaks is normal; a sustained step change usually reflects a business event — a campaign, - an onboarding, a new integration — and is worth being able to explain. - - -
- -
- - The share each disposition accounts for matters more than the counts. Good mail should - dominate; anything else growing as a proportion is the signal. - - -
- -
- - Inbound, outbound and internal traffic in proportion. An unusual outbound share is the - one to watch: mail leaving in volume that the business did not generate is how a - compromised mailbox or an unsecured relay first shows up in these figures. - - -
-
- - {/* SENDERS & SPAM TARGETS */} - -
- - The heaviest senders are normally the ones you would expect — shared mailboxes, - ticketing systems, scan-to-email devices, marketing platforms. What is worth a second - look is a name that does not belong on that list. A user account sending at machine - volume is either an unmanaged automation nobody documented, or a mailbox someone else is - using. - - {topSenders.length > 0 ? ( - ({ - name: row.Name ?? row.name ?? 'Unknown', - count: num(row.Count ?? row.count), - }))} - limit={10} - /> - ) : ( - - Microsoft returned no top-sender breakdown for this window. - - )} -
- -
- - Unwanted mail does not spread evenly. A handful of addresses — usually the published - ones, and the people whose names appear on the website — absorb most of it, and those - same addresses are the ones a targeted attempt will use. Concentration here identifies - exactly who benefits most from stricter policy and from being asked to be careful. - - {topSpamRecipients.length > 0 ? ( - ({ - name: row.Name ?? row.name ?? 'Unknown', - count: num(row.Count ?? row.count), - }))} - limit={10} - /> - ) : ( - - No recipient stands out as absorbing spam over this window. - - )} -
-
- - {/* RECOMMENDATIONS */} - -
- - Ordered by what this window's data actually shows, rather than by a generic checklist. - - -
- -
- -
-
-
- ) -} +import { useSettings } from '../../hooks/use-settings' +import { ServerPdfPane, useServerPdf } from './useServerPdf' +// The report PDF is rendered server-side (ExecGetMailFlowReportPdf) by the shared CIPPSharp component +// kit, which re-gathers and aggregates the mail flow data; the button fetches the finished PDF as a +// blob for preview and download. export const MailFlowReportButton = ({ mailFlowData, tenantName, disabled = false }) => { const [dialogOpen, setDialogOpen] = useState(false) - const [generatedOn, setGeneratedOn] = useState('') - const brandingSettings = useBrandingSettings() - const variables = useReportVariables() + const tenantFilter = useSettings().currentTenant + const days = Number(mailFlowData?.days) || 14 const hasData = Object.keys(mailFlowData?.totals ?? {}).length > 0 - - const handleOpen = () => { - setGeneratedOn( - new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) - ) - setDialogOpen(true) - } - - const documentNode = ( - - ) + const pdf = useServerPdf({ + url: `/api/ExecGetMailFlowReportPdf?tenantFilter=${encodeURIComponent(tenantFilter)}&days=${days}`, + enabled: dialogOpen, + }) + const handleOpen = () => setDialogOpen(true) + const handleClose = () => setDialogOpen(false) + const fileName = `Mail_Flow_Report_${(tenantName || 'report').replace(/[^a-zA-Z0-9]/g, '_')}_${new Date().toISOString().split('T')[0]}.pdf` return ( <> @@ -394,7 +48,7 @@ export const MailFlowReportButton = ({ mailFlowData, tenantName, disabled = fals setDialogOpen(false)} + onClose={handleClose} maxWidth="lg" fullWidth slotProps={{ @@ -411,40 +65,28 @@ export const MailFlowReportButton = ({ mailFlowData, tenantName, disabled = fals Mail Flow Report Preview - setDialogOpen(false)} size="small"> +
- {dialogOpen && ( - - {documentNode} - - )} + - - Close + - )} - + Download PDF + diff --git a/src/components/CippPdf/PermissionsReportButton.jsx b/src/components/CippPdf/PermissionsReportButton.jsx index 26957d02cc76..560fd92c58e9 100644 --- a/src/components/CippPdf/PermissionsReportButton.jsx +++ b/src/components/CippPdf/PermissionsReportButton.jsx @@ -3,7 +3,6 @@ import { CippIcons } from '../../utils/icon-registry' import { Box, Button, - CircularProgress, Dialog, DialogActions, DialogContent, @@ -12,435 +11,22 @@ import { Tooltip, Typography, } from '@mui/material' -import { PDFDownloadLink } from '@react-pdf/renderer' -import { CippPdfPreview } from './CippPdfPreview' -import { - AlertBox, - Bold, - BulletList, - ClearBox, - ContentPage, - CoverMeta, - DataTable, - InfoBox, - Paragraph, - REPORT_COLOURS, - ReportDocument, - Section, - StatRow, - severityColour, -} from './index' -import { useReportVariables } from './useReportVariables' -import { useBrandingSettings } from './useBrandingSettings' - -const nz = (value) => Number(value ?? 0) -const plural = (count, singular, pluralForm) => - `${count} ${count === 1 ? singular : (pluralForm ?? `${singular}s`)}` - -/** - * Grades permission exposure. A tenant-wide claim dominates because it grants access to the whole - * organisation in one entry, regardless of what the site's membership says. - */ -const assessExposure = (summary) => { - let score = 0 - if (nz(summary.broadClaimGrants) > 0) score += 5 - if (nz(summary.externalGrants) > 0) score += 3 - if (nz(summary.directFullControlGrants) > 0) score += 2 - if (nz(summary.uniquePermissionLibraries) > 0) score += 1 - - if (score >= 7) return { level: 'High', severity: 'high' } - if (score >= 3) return { level: 'Medium', severity: 'medium' } - return { level: 'Low', severity: 'low' } -} - -const CLAIM_LABELS = { - Everyone: 'Everyone (includes external users)', - EveryoneExceptExternal: 'Everyone except external users', - AllUsers: 'All Users', -} - -// Exported so the branding preview can render this report against sample data, and so tests can -// render it to a real PDF. -export const PermissionsReportDocument = ({ - permissionsData, - brandingSettings, - tenantName, - generatedOn, - variables, -}) => { - const summary = permissionsData?.summary ?? {} - const assignments = permissionsData?.assignments ?? [] - const skippedSites = permissionsData?.skippedSites ?? [] - - const exposure = assessExposure(summary) - const exposureColour = severityColour(exposure.severity) - - // Placeholder rows carry no principal, and Limited Access grants nothing on its own — both are - // excluded from the counts, so they have to be excluded here or the tables contradict them. - const realAssignments = assignments.filter((row) => row.principalId && !row.isSystemManaged) - const broadClaimRows = realAssignments.filter((row) => row.broadClaim) - const externalGrantRows = realAssignments.filter((row) => row.isGuest === true) - const fullControlRows = realAssignments.filter( - (row) => row.permissionLevel === 'Full Control' && row.principalType !== 'SharePoint Group' - ) - const libraryRows = realAssignments.filter((row) => row.scope === 'Library') - - // getAllSites does not always return a display name, so fall back to the URL. - const siteLabel = (row) => row.siteName || row.siteUrl || 'Unnamed site' - const scopeLabel = (row) => - row.scope === 'Library' ? `${siteLabel(row)} / ${row.libraryTitle}` : siteLabel(row) - - return ( - - } - > - {/* EXECUTIVE SUMMARY */} - - -
- - Permissions are set by administrators on a site or document library and decide who is - structurally allowed in. They change rarely, which is what makes them worth auditing: a - permission granted for one project stays in place indefinitely, and a permission granted - to the whole organisation looks identical to one granted to a single team until somebody - reads it. This report covers {tenantName}. - - - 0 ? REPORT_COLOURS.danger : undefined, - }, - { - value: nz(summary.externalGrants), - label: 'External Grants', - colour: nz(summary.externalGrants) > 0 ? REPORT_COLOURS.warning : undefined, - }, - { - value: nz(summary.directFullControlGrants), - label: 'Direct Full Control', - colour: - nz(summary.directFullControlGrants) > 0 ? REPORT_COLOURS.warning : undefined, - }, - { - value: nz(summary.uniquePermissionLibraries), - label: 'Detached Libraries', - }, - ]} - /> - - - {exposure.level === 'High' && - 'Content is reachable by people it was never meant for. A tenant-wide grant is present, which opens the content to the entire organisation regardless of who the site membership says should have it — and it is the most common reason material turns up unexpectedly in search results and AI assistant answers. Treat the findings below as immediate remediation work.'} - {exposure.level === 'Medium' && - 'Access extends past the intended audience in places. Each finding below is individually manageable, but each one widens what a single compromised account reaches.'} - {exposure.level === 'Low' && - 'Permissions broadly match what the structure intends. No tenant-wide grants were found. Continue reviewing periodically, particularly after site or library changes.'} - -
- -
- - {nz(summary.sitesScanned)} SharePoint sites and {nz(summary.librariesScanned)} document - libraries were read, producing {nz(summary.totalAssignments)} permission assignments. - Data is taken from the last completed sync, not read live. - - - Permissions are reported as grant paths, not effective access — a group holding a - permission is one entry and its members are not expanded, so a person may hold access - that shows here only via their group. Permissions on individual folders and files are - not enumerated. OneDrive personal sites are out of scope. Access handed out by sharing - link is a separate path, covered by the Sharing Report. - - {skippedSites.length > 0 ? ( - - These sites could not be read on the most recent scan. Where a site was read - successfully before, its earlier results are still shown and are as old as that scan; - a site never read successfully contributes nothing. Either way, an absence of findings - for these sites is not evidence of good configuration. - - ) : null} -
-
- - {/* FINDINGS */} - - -
- - SharePoint offers a handful of special audiences — Everyone, Everyone except external - users, and All Users — that resolve to the whole organisation rather than to named - people. A library carrying one is readable by every employee no matter what the site's - membership says, and it is the single most common cause of data appearing in search - results or AI assistant answers where it was not expected. - - {broadClaimRows.length > 0 ? ( - <> - - Confirm the content is genuinely meant to be organisation-wide. If not, replace the - grant with a specific group — one edit removes access for everyone who was never - meant to have it. - - ({ - location: scopeLabel(row), - audience: CLAIM_LABELS[row.broadClaim] ?? row.broadClaim, - level: row.permissionLevel, - }))} - /> - - ) : ( - - No site or library grants access to Everyone, Everyone except external users, or All - Users. - - )} -
- -
- - Guest accounts holding permissions retain that access until somebody removes it — unlike - a sharing link, nothing expires it. Guests from finished projects are a common source of - standing access nobody is reviewing. - - {externalGrantRows.length > 0 ? ( - <> - - Verify each guest still needs access and that the relationship is current. - - ({ - location: scopeLabel(row), - identity: row.email || row.title || row.loginName, - level: row.permissionLevel, - }))} - /> - - ) : ( - - No guest or external identity holds a permission on a scanned site or library. - - )} -
-
- - {/* FINDINGS CONTINUED */} - - -
- - Every site has an Owners group that holds Full Control by design, and that is expected. - Full Control granted straight to a person or a directory group is different: it sits - outside the membership structure, so it is not removed when someone leaves a team and it - is easy to overlook when reviewing who administers a site. - - {fullControlRows.length > 0 ? ( - <> - - Move these into the site's Owners group where the access is legitimate, so - membership changes take effect automatically. - - ({ - location: scopeLabel(row), - principal: row.title || row.email || row.loginName, - type: row.principalType, - }))} - /> - - ) : ( - - No user or directory group holds Full Control outside a site's Owners group. - - )} -
- -
- - A library normally inherits from its site, so managing the site manages everything in - it. A detached library keeps its own permissions and later site-level changes no longer - reach it. That is legitimate when deliberate and a blind spot when not — removing - somebody from the site does not remove them here. - - {nz(summary.uniquePermissionLibraries) > 0 ? ( - - {nz(summary.uniquePermissionLibraries)} of {nz(summary.librariesScanned)} libraries no - longer inherit from their site. Their assignments are listed in the appendix. Review - whether each detachment was intentional and is still needed. - - ) : ( - - Every scanned library takes its permissions from its site, so site-level access - management covers them all. - - )} -
-
- - {/* RECOMMENDATIONS */} - - -
- - Ordered by how much access each removes relative to the effort involved. - - -
- -
- -
-
- - {/* APPENDIX */} - - -
- ({ - site: siteLabel(row), - library: row.libraryTitle, - principal: row.title || row.email || row.loginName, - level: row.permissionLevel, - }))} - limit={40} - emptyText="No libraries hold their own permissions." - /> -
-
-
- ) -} +import { useSettings } from '../../hooks/use-settings' +import { ServerPdfPane, useServerPdf } from './useServerPdf' +// The report PDF is rendered server-side (ExecGetPermissionsReportPdf) by the shared CIPPSharp +// component kit; the button fetches the finished PDF as a blob for preview and download. export const PermissionsReportButton = ({ permissionsData, tenantName }) => { const [dialogOpen, setDialogOpen] = useState(false) - const [generatedOn, setGeneratedOn] = useState('') - const brandingSettings = useBrandingSettings() - const variables = useReportVariables() + const tenantFilter = useSettings().currentTenant const hasData = !!permissionsData?.summary - - const handleOpen = () => { - setGeneratedOn( - new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) - ) - setDialogOpen(true) - } - - const documentNode = ( - - ) + const pdf = useServerPdf({ + url: `/api/ExecGetPermissionsReportPdf?tenantFilter=${encodeURIComponent(tenantFilter)}`, + enabled: dialogOpen, + }) + const handleOpen = () => setDialogOpen(true) + const handleClose = () => setDialogOpen(false) + const fileName = `Permissions_Report_${(tenantName || 'report').replace(/[^a-zA-Z0-9]/g, '_')}_${new Date().toISOString().split('T')[0]}.pdf` return ( <> @@ -460,7 +46,7 @@ export const PermissionsReportButton = ({ permissionsData, tenantName }) => { setDialogOpen(false)} + onClose={handleClose} maxWidth="lg" fullWidth slotProps={{ @@ -477,40 +63,28 @@ export const PermissionsReportButton = ({ permissionsData, tenantName }) => { Permissions Report Preview - setDialogOpen(false)} size="small"> + - {dialogOpen && ( - - {documentNode} - - )} + - - Close + - )} - + Download PDF + diff --git a/src/components/CippPdf/SharingReportButton.jsx b/src/components/CippPdf/SharingReportButton.jsx index 59df3dfb54e1..e96298e52147 100644 --- a/src/components/CippPdf/SharingReportButton.jsx +++ b/src/components/CippPdf/SharingReportButton.jsx @@ -3,7 +3,6 @@ import { CippIcons } from '../../utils/icon-registry' import { Box, Button, - CircularProgress, Dialog, DialogActions, DialogContent, @@ -12,426 +11,22 @@ import { Tooltip, Typography, } from '@mui/material' -import { PDFDownloadLink } from '@react-pdf/renderer' -import { CippPdfPreview } from './CippPdfPreview' -import { - AlertBox, - Bold, - BulletList, - ClearBox, - ContentPage, - CoverMeta, - DataTable, - InfoBox, - Paragraph, - REPORT_COLOURS, - ReportDocument, - Section, - StatRow, - severityColour, -} from './index' -import { useReportVariables } from './useReportVariables' -import { useBrandingSettings } from './useBrandingSettings' - -const nz = (value) => Number(value ?? 0) -const joinList = (value) => (Array.isArray(value) ? value.join(', ') : (value ?? '')) -const plural = (count, singular, pluralForm) => - `${count} ${count === 1 ? singular : (pluralForm ?? `${singular}s`)}` - -/** - * Grades sharing exposure. An anonymous link that also allows editing dominates because it is the - * only combination that lets an unidentified person change content, with no record of who did it. - */ -const assessExposure = (summary) => { - let score = 0 - if (nz(summary.anonymousEditLinks) > 0) score += 5 - if (nz(summary.neverExpiringAnonymous) > 0) score += 3 - if (nz(summary.anonymousLinks) > 0) score += 2 - if (nz(summary.folderShares) > 0) score += 2 - if (nz(summary.externalLinks) > 0) score += 1 - - if (score >= 7) return { level: 'High', severity: 'high' } - if (score >= 3) return { level: 'Medium', severity: 'medium' } - return { level: 'Low', severity: 'low' } -} - -// Exported so the branding preview can render this report against sample data, and so tests can -// render it to a real PDF. -export const SharingReportDocument = ({ - sharingData, - brandingSettings, - tenantName, - generatedOn, - variables, -}) => { - const summary = sharingData?.summary ?? {} - const links = sharingData?.links ?? [] - const topRecipients = sharingData?.topRecipients ?? [] - const topLibraries = sharingData?.topLibraries ?? [] - - const exposure = assessExposure(summary) - const exposureColour = severityColour(exposure.severity) - - const canEdit = (row) => - joinList(row.roles).includes('write') || joinList(row.roles).includes('owner') - const anonEditRows = links.filter((row) => row.classification === 'Anonymous' && canEdit(row)) - const neverExpiringRows = links.filter( - (row) => row.classification === 'Anonymous' && !row.expirationDateTime - ) - const folderShareRows = links.filter( - (row) => row.itemType === 'Folder' && ['Anonymous', 'External'].includes(row.classification) - ) - const externalRows = links.filter((row) => row.classification === 'External') - - const locationOf = (row) => - `${row.siteName || row.siteUrl || 'Unknown site'}${row.driveName ? ` / ${row.driveName}` : ''}` - const expiryOf = (row) => - row.expirationDateTime ? new Date(row.expirationDateTime).toLocaleDateString() : 'Never' - - return ( - - } - > - {/* EXECUTIVE SUMMARY */} - - -
- - Sharing links are created by users on individual files and folders. They hand out access - outside the permission structure an administrator sets on a site or library, they - accumulate quietly as people work, and nothing prompts anyone to review them. This - report covers what exists today across SharePoint and OneDrive in{' '} - {tenantName}. - - - 0 ? REPORT_COLOURS.danger : undefined, - }, - { - value: nz(summary.neverExpiringAnonymous), - label: 'Anonymous, No Expiry', - colour: nz(summary.neverExpiringAnonymous) > 0 ? REPORT_COLOURS.danger : undefined, - }, - { - value: nz(summary.folderShares), - label: 'Shared Folders', - colour: nz(summary.folderShares) > 0 ? REPORT_COLOURS.warning : undefined, - }, - { - value: nz(summary.externalRecipients), - label: 'External Recipients', - colour: nz(summary.externalRecipients) > 0 ? REPORT_COLOURS.warning : undefined, - }, - ]} - /> - - - {exposure.level === 'High' && - 'Content is reachable by people who cannot be identified. Anonymous links work for anyone holding them, with no sign-in and no record of use — and where those links also allow editing, changes are attributed to nobody. Treat the findings below as immediate remediation work.'} - {exposure.level === 'Medium' && - 'Sharing extends beyond the intended audience in places. Each finding below is individually manageable, but every open link widens what a single forwarded message can expose.'} - {exposure.level === 'Low' && - 'No high-risk sharing was found. Links are scoped and time-bounded. Continue reviewing periodically, since sharing accumulates as projects come and go.'} - -
- -
- - {nz(summary.totalLinks)} sharing links and external shares across{' '} - {nz(summary.sharePointSites)} SharePoint sites, {nz(summary.teamsSites)} Teams-connected - sites and {nz(summary.oneDriveAccounts)} OneDrive accounts, covering{' '} - {nz(summary.itemsShared)} distinct shared items. Data is taken from the last completed - sync, not read live. - - - This report covers sharing links only. Permissions granted on a site or document library - are a separate access path, governed differently, and are covered by the Permissions - Report. A clean result here does not mean access is restricted — it means nothing has - been shared out by link. - -
-
- - {/* FINDINGS */} - - -
- - An anonymous link works for anyone who holds it — no sign-in, no record of who used it. - When that link also grants editing, anyone it has been forwarded to can change or delete - the content, and the change is attributed to nobody. This is the only combination that - allows untraceable modification. - - {anonEditRows.length > 0 ? ( - <> - - Revoke these, or downgrade them to view-only where the sharing is still needed. - - ({ - item: row.fileName, - location: locationOf(row), - expires: expiryOf(row), - }))} - /> - - ) : ( - - No anonymous link grants write access. - - )} -
- -
- - A link with no expiry date stays live indefinitely, long after the reason for sharing - has passed. Expiry is the only control that withdraws this access without somebody - remembering to do it. - - {neverExpiringRows.length > 0 ? ( - <> - - Set a tenant-level default expiry so this cannot recur, then revoke the existing - links that are no longer needed. - - ({ - item: row.fileName, - location: locationOf(row), - roles: joinList(row.roles), - }))} - /> - - ) : ( - - Every anonymous link has an expiry date set. - - )} -
-
- - {/* FINDINGS CONTINUED */} - - -
- - Sharing a folder shares everything inside it, including anything added later. The - recipient's access grows over time without anyone re-approving it, which is the main way - a small share quietly becomes a large one. - - {folderShareRows.length > 0 ? ( - <> - - Check what each folder holds now, not what it held when it was shared. - - ({ - item: row.fileName, - location: locationOf(row), - audience: row.classification, - }))} - /> - - ) : ( - - External and anonymous shares point at individual files rather than folders. - - )} -
- -
- - Every external recipient is a person outside the organisation holding access that was - granted individually, usually for a specific piece of work. Nothing withdraws it when - that work ends. - - {topRecipients.length > 0 ? ( - <> - - {plural(nz(summary.externalRecipients), 'external identity', 'external identities')}{' '} - hold shared content, across {plural(externalRows.length, 'share')}. The most - frequent are listed below. - - ({ - recipient: row.recipient, - shares: String(row.links), - }))} - limit={15} - /> - - ) : ( - - Nothing has been shared with an identity outside the organisation. - - )} -
- - {topLibraries.length > 0 && ( -
- - The libraries below account for the most sharing links. Concentration is not a problem - in itself, but it shows where a review will have the most effect. - - ({ - library: row.library, - links: String(row.links), - }))} - limit={10} - /> -
- )} -
- - {/* RECOMMENDATIONS */} - - -
- - Ordered by how much exposure each removes relative to the effort involved. - - -
- -
- -
-
-
- ) -} +import { useSettings } from '../../hooks/use-settings' +import { ServerPdfPane, useServerPdf } from './useServerPdf' +// The report PDF is rendered server-side (ExecGetSharingReportPdf) by the shared CIPPSharp component +// kit; the button fetches the finished PDF as a blob for preview and download. export const SharingReportButton = ({ sharingData, tenantName }) => { const [dialogOpen, setDialogOpen] = useState(false) - const [generatedOn, setGeneratedOn] = useState('') - const brandingSettings = useBrandingSettings() - const variables = useReportVariables() + const tenantFilter = useSettings().currentTenant const hasData = !!sharingData?.summary - - const handleOpen = () => { - setGeneratedOn( - new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) - ) - setDialogOpen(true) - } - - const documentNode = ( - - ) + const pdf = useServerPdf({ + url: `/api/ExecGetSharingReportPdf?tenantFilter=${encodeURIComponent(tenantFilter)}`, + enabled: dialogOpen, + }) + const handleOpen = () => setDialogOpen(true) + const handleClose = () => setDialogOpen(false) + const fileName = `Sharing_Report_${(tenantName || 'report').replace(/[^a-zA-Z0-9]/g, '_')}_${new Date().toISOString().split('T')[0]}.pdf` return ( <> @@ -451,7 +46,7 @@ export const SharingReportButton = ({ sharingData, tenantName }) => { setDialogOpen(false)} + onClose={handleClose} maxWidth="lg" fullWidth slotProps={{ @@ -468,40 +63,28 @@ export const SharingReportButton = ({ sharingData, tenantName }) => { Sharing Report Preview - setDialogOpen(false)} size="small"> + - {dialogOpen && ( - - {documentNode} - - )} + - - Close + - )} - + Download PDF + diff --git a/src/components/CippPdf/index.js b/src/components/CippPdf/index.js index c78de85ec937..d7f4da9d91f0 100644 --- a/src/components/CippPdf/index.js +++ b/src/components/CippPdf/index.js @@ -1,13 +1,6 @@ -// One entry point for the PDF report system. Reports import from here rather than reaching into -// individual modules, so the internal split between theme, styles, primitives and charts can move -// without touching every report. - -// Imported for its side effects: the global hyphenation and emoji registrations. First, so they -// are in place before any report builds a document. -import './reportFonts' - -export { noHyphenation } from './reportFonts' -export { measureText, wrapLongTokens } from './measureText' +// One entry point for what remains of the client-side report kit now that the PDFs render +// server-side (CIPPSharp): the theme and page metrics the report builder and the branding editor's +// cover mock still read, cover-image resolution, and the branding-settings hook. export { DEFAULT_BRAND_COLOUR, @@ -39,41 +32,6 @@ export { createReportStyles, } from './reportPdfStyles' -export { - AlertBox, - Bold, - Bullet, - BulletList, - ClearBox, - Columns, - ContentPage, - CoverMeta, - CoverPage, - DataTable, - HeroPage, - INFO_TONES, - InfoBox, - Note, - PageFooter, - PageHeader, - Paragraph, - ProgressList, - ReportPage, - STATUS_TONES, - Section, - StatRow, - StatusText, - Watermark, - severityColour, - splitAccentTitle, - tableColumns, -} from './reportPdfPrimitives' - -export { ReportDocument } from './ReportDocument' -export { ReportProvider, useReport, useReportStyles } from './reportContext' - -export { BarChart, CHART_KINDS, DonutChart, ReportChart, TrendChart, normaliseChartData } from './charts' - export { COVER_IMAGE_NOT_FOUND, COVER_STOCK_NONE, @@ -86,7 +44,6 @@ export { resolveCoverImage, } from './resolveCoverImage' -export { useReportVariables } from './useReportVariables' export { useBrandingSettings, fetchBrandingSettings, diff --git a/src/components/CippPdf/previewSampleData.js b/src/components/CippPdf/previewSampleData.js index 205bf5afdc0c..8eff238fba79 100644 --- a/src/components/CippPdf/previewSampleData.js +++ b/src/components/CippPdf/previewSampleData.js @@ -1,379 +1,89 @@ -/** - * Sample data for the branding preview, and for nothing else. - * - * ───────────────────────────────────────────────────────────────────────────── - * THIS MUST NEVER BE IMPORTED BY A REPORT. - * ───────────────────────────────────────────────────────────────────────────── - * - * The point of the branding preview is to show what a report will look like before a tenant has - * been scanned, so someone tuning colours is not staring at an empty page. That means inventing - * numbers — and invented numbers reaching a client report would be far worse than an empty section. - * A report that has no data must keep saying so. - * - * The separation is structural rather than a convention to remember: this module is imported only - * by CippBrandingReportPreview, and `previewSampleData.test.js` fails the build if anything under - * `src/` other than the preview imports it. - * - * Everything here is deliberately obvious as fiction — Contoso, round numbers, example.com — so a - * screenshot of the preview can never be mistaken for a real client's report. - */ +// The figures the branding editor's cover mock quotes for each report. They are the headline +// values of the server-side samples (backend/Config/ReportSamples/.json) the live preview +// renders, copied here because the static frontend build cannot read the backend tree; keep the two +// in step so the mock and the rendered preview describe the same report. export const SAMPLE_TENANT_NAME = 'Contoso (sample data)' -/** Executive report — user counts, secure score history, licences, devices, CA policies. */ -export const SAMPLE_EXECUTIVE = { - userStats: { licensedUsers: 128, unlicensedUsers: 12, guests: 9, globalAdmins: 3 }, - secureScoreData: { - isSuccess: true, - translatedData: { currentScore: 61, maxScore: 100, percentageCurrent: 61 }, - secureScore: { - data: { - Results: [ - { createdDateTime: '2026-08-04T00:00:00Z', currentScore: 61 }, - { createdDateTime: '2026-08-03T00:00:00Z', currentScore: 60 }, - { createdDateTime: '2026-08-02T00:00:00Z', currentScore: 58 }, - { createdDateTime: '2026-08-01T00:00:00Z', currentScore: 57 }, - { createdDateTime: '2026-07-31T00:00:00Z', currentScore: 55 }, - { createdDateTime: '2026-07-30T00:00:00Z', currentScore: 52 }, - { createdDateTime: '2026-07-29T00:00:00Z', currentScore: 50 }, - ], - }, - }, - }, - // Field names match what the report's tables actually read. They were previously invented - // (`skuPartNumber`, `consumedUnits`) and every cell fell through to its 'N/A' fallback, so the - // preview showed an empty-looking table while the real report showed data. - licensingData: [ - { License: 'Microsoft 365 E3', CountUsed: 88, CountAvailable: 12, TotalLicenses: 100 }, - { License: 'Exchange Online (Plan 1)', CountUsed: 27, CountAvailable: 3, TotalLicenses: 30 }, - { License: 'Microsoft Defender for Office 365', CountUsed: 64, CountAvailable: 36, TotalLicenses: 100 }, - ], - // A plain array, matching `deviceData?.data?.Results` in the real report — the wrapper the sample - // used to carry meant `Array.isArray` failed and the whole Device Management section was skipped. - deviceData: [ - { - deviceName: 'SAMPLE-LT-001', - operatingSystem: 'Windows', - complianceState: 'compliant', - isEncrypted: true, - lastSyncDateTime: '2026-08-05T09:14:00Z', - }, - { - deviceName: 'SAMPLE-LT-002', - operatingSystem: 'Windows', - complianceState: 'noncompliant', - isEncrypted: false, - lastSyncDateTime: '2026-07-28T17:02:00Z', - }, - { - deviceName: 'SAMPLE-MB-003', - operatingSystem: 'iOS', - complianceState: 'compliant', - isEncrypted: true, - lastSyncDateTime: '2026-08-05T07:45:00Z', - }, - { - deviceName: 'SAMPLE-MB-004', - operatingSystem: 'Android', - complianceState: 'compliant', - 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: [ - { - displayName: 'Require MFA for all users', - state: 'enabled', - includeApplications: 'All', - builtInControls: ['mfa'], - }, - { - displayName: 'Block legacy authentication', - state: 'enabled', - includeApplications: 'All', - builtInControls: ['block'], - }, - { - displayName: 'Require compliant device for Exchange', - state: 'enabledForReportingButNotEnforced', - includeApplications: 'Exchange Online', - builtInControls: ['compliantDevice'], - }, - ], - // `driftComplianceData?.data` in the real report: the array itself, not a wrapper around it. - driftComplianceData: [ - { - tenantFilter: 'contoso.com', - alignedCount: 42, - currentDeviations: [ - { standardName: 'standards.AntiPhishPolicy', receivedValue: 'Disabled' }, - { standardName: 'standards.SafeLinksPolicy', receivedValue: 'Disabled' }, - ], - acceptedDeviations: [{ standardName: 'standards.AuditLog', receivedValue: 'Custom' }], - deniedDeviations: [{ standardName: 'standards.DisableBasicAuth', receivedValue: 'Enabled' }], - customerSpecificDeviations: [{ standardName: 'standards.Guests', receivedValue: 'Allowed' }], - }, - ], - // Drives two things: the Security Standards page, and the Applied Standards section of the drift - // pages — which is built from this rather than from driftComplianceData, so without it that page - // rendered empty and never appeared in the preview at all. - // - // Keys are real standard names where possible, so they resolve against the generated - // standards.json catalogue and show its wording. A key it does not know still renders, through - // the report's own fallback naming — which is worth exercising here too. - standardsCompareData: [ - { - tenantFilter: 'contoso.com', - 'standards.AntiPhishPolicy': { Value: true }, - 'standards.SafeLinksPolicy': { Value: true }, - 'standards.AuditLog': { CurrentValue: 'Enabled', ExpectedValue: 'Enabled' }, - 'standards.Guests': { CurrentValue: 'Allowed', ExpectedValue: 'Blocked' }, - 'standards.PasswordExpireDisabled': { Value: true }, - 'standards.DisableBasicAuth': { CurrentValue: 'Partial', ExpectedValue: 'Disabled' }, - 'standards.IntuneTemplate.8f2a1c4e-6b3d-4f5a-9e7c-1d2b3a4c5e6f': { Value: true }, - }, - ], - standardTemplatesData: [ - { - standards: { - IntuneTemplate: [ - { - TemplateList: { - value: '8f2a1c4e-6b3d-4f5a-9e7c-1d2b3a4c5e6f', - label: 'Windows Device Compliance', - }, - }, - ], - }, - }, - ], -} - -/** Report builder — one of each block type, so every renderer is exercised in the preview. */ -export const SAMPLE_REPORT_BUILDER_BLOCKS = [ - { - id: 'sample-1', - type: 'blank', - title: 'Summary', - static: true, - content: - '

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 ( + + + Generating report… + + ) + } + if (error) { + return ( + + + {errorText} + + + ) + } + if (!pdfUrl) return null + return ( +