diff --git a/package.json b/package.json index 748f3dee1700..5476801e962e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cipp", - "version": "10.8.2", + "version": "10.8.3", "author": "CIPP Contributors", "homepage": "https://cipp.app/", "bugs": { diff --git a/public/version.json b/public/version.json index e5ab9ee3cb92..682cb83714dc 100644 --- a/public/version.json +++ b/public/version.json @@ -1,3 +1,3 @@ { - "version": "10.8.2" -} + "version": "10.8.3" +} \ No newline at end of file diff --git a/src/components/CippBaselines/CippBaselineStandardItem.jsx b/src/components/CippBaselines/CippBaselineStandardItem.jsx index c113c061d12c..a2ac421247cd 100644 --- a/src/components/CippBaselines/CippBaselineStandardItem.jsx +++ b/src/components/CippBaselines/CippBaselineStandardItem.jsx @@ -350,14 +350,14 @@ export const CippBaselineStandardItem = ({ - {standard.prepare ? ( + {standard.prepare || standard.package ? ( // Template-backed standards (CA/Intune): the real expected value is the FULL - // selected template, normalized by the engine at run time - a rendered - // preview here would show only the template reference and mislead. + // selected template (or every template in the package), resolved by the + // engine at run time - a rendered preview here would mislead. - The expected configuration is the full selected template - use the - preview button on the template picker to inspect it. The engine - compares every setting in it against the tenant on each run. + {standard.package + ? 'This deploys and drift-checks every template tagged with the selected package. Membership is resolved fresh on every run: tagging a template with this package adds it to the baseline automatically, untagging removes it. All templates in the package share the options configured above.' + : 'The expected configuration is the full selected template - use the preview button on the template picker to inspect it. The engine compares every setting in it against the tenant on each run.'} ) : ( <> diff --git a/src/components/CippComponents/CIPPDeviceCodeButton.js b/src/components/CippComponents/CIPPDeviceCodeButton.js deleted file mode 100644 index 16711f8d1332..000000000000 --- a/src/components/CippComponents/CIPPDeviceCodeButton.js +++ /dev/null @@ -1,252 +0,0 @@ -import { useState, useEffect } from "react"; -import { - Alert, - Button, - Typography, - CircularProgress, - Box, -} from "@mui/material"; -import { ApiGetCall } from "../../api/ApiCall"; - -/** - * CIPPDeviceCodeButton - A button component for Microsoft 365 OAuth authentication using device code flow - * - * @param {Object} props - Component props - * @param {Function} props.onAuthSuccess - Callback function called when authentication is successful with token data - * @param {Function} props.onAuthError - Callback function called when authentication fails with error data - * @param {string} props.buttonText - Text to display on the button (default: "Login with Device Code") - * @param {boolean} props.showResults - Whether to show authentication results in the component (default: true) - * @returns {JSX.Element} The CIPPDeviceCodeButton component - */ -export const CIPPDeviceCodeButton = ({ - onAuthSuccess, - onAuthError, - buttonText = "Login with Device Code", - showResults = true, -}) => { - const [authInProgress, setAuthInProgress] = useState(false); - const [authError, setAuthError] = useState(null); - const [deviceCodeInfo, setDeviceCodeInfo] = useState(null); - const [currentStep, setCurrentStep] = useState(0); - const [pollInterval, setPollInterval] = useState(null); - const [tokens, setTokens] = useState({ - accessToken: null, - refreshToken: null, - accessTokenExpiresOn: null, - refreshTokenExpiresOn: null, - username: null, - tenantId: null, - onmicrosoftDomain: null, - }); - - // Get application ID information from API - const appIdInfo = ApiGetCall({ - url: `/api/ExecListAppId`, - queryKey: `ExecListAppId`, - waiting: true, - }); - - // Handle closing the error - const handleCloseError = () => { - setAuthError(null); - }; - - // Clear polling interval when component unmounts - useEffect(() => { - return () => { - if (pollInterval) { - clearInterval(pollInterval); - } - }; - }, [pollInterval]); - - // Start device code authentication - const startDeviceCodeAuth = async () => { - try { - setAuthInProgress(true); - setAuthError(null); - setDeviceCodeInfo(null); - setCurrentStep(1); - - // Call the API to start device code flow - const response = await fetch(`/api/ExecSAMSetup?CreateSAM=true`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - - const data = await response.json(); - - if (response.ok && data.code) { - // Store device code info - setDeviceCodeInfo({ - user_code: data.code, - verification_uri: data.url, - expires_in: 900, // Default to 15 minutes if not provided - }); - - // Start polling for token - const interval = setInterval(checkAuthStatus, 5000); - setPollInterval(interval); - } else { - // Error getting device code - setAuthError({ - errorCode: "device_code_error", - errorMessage: data.message || "Failed to get device code", - timestamp: new Date().toISOString(), - }); - setAuthInProgress(false); - if (onAuthError) onAuthError(error); - } - } catch (error) { - console.error("Error starting device code authentication:", error); - setAuthError({ - errorCode: "device_code_error", - errorMessage: error.message || "An error occurred during device code authentication", - timestamp: new Date().toISOString(), - }); - setAuthInProgress(false); - if (onAuthError) onAuthError(error); - } - }; - - // Check authentication status - const checkAuthStatus = async () => { - try { - // Call the API to check auth status - const response = await fetch(`/api/ExecSAMSetup?CheckSetupProcess=true&step=1`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - - const data = await response.json(); - - if (response.ok) { - if (data.step === 2) { - // Authentication successful - clearInterval(pollInterval); - setPollInterval(null); - - // Process token data - const tokenData = { - accessToken: "Successfully authenticated", - refreshToken: "Token stored on server", - accessTokenExpiresOn: new Date(Date.now() + 3600 * 1000), // 1 hour from now - refreshTokenExpiresOn: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), // 90 days from now - username: "authenticated user", - tenantId: data.tenantId || "unknown", - onmicrosoftDomain: null, - }; - - // Store tokens in component state - setTokens(tokenData); - setDeviceCodeInfo(null); - setCurrentStep(2); - - // Call the onAuthSuccess callback if provided - if (onAuthSuccess) onAuthSuccess(tokenData); - - // Update UI state - setAuthInProgress(false); - } - } else { - // Error checking auth status - clearInterval(pollInterval); - setPollInterval(null); - - setAuthError({ - errorCode: "auth_status_error", - errorMessage: data.message || "Failed to check authentication status", - timestamp: new Date().toISOString(), - }); - setAuthInProgress(false); - if (onAuthError) onAuthError({ - errorCode: "auth_status_error", - errorMessage: data.message || "Failed to check authentication status", - timestamp: new Date().toISOString(), - }); - } - } catch (error) { - console.error("Error checking auth status:", error); - // Don't stop polling on transient errors - } - }; - - return ( -
- - - {!appIdInfo.isLoading && - !appIdInfo?.data?.applicationId && ( - - The Application ID is not valid. Please check your configuration. - - ) - } - - {showResults && ( - - {deviceCodeInfo && authInProgress ? ( - - Device Code Authentication - - To sign in, use a web browser to open the page {deviceCodeInfo.verification_uri} and enter the code {deviceCodeInfo.user_code} to authenticate. - - - Code expires in {Math.round(deviceCodeInfo.expires_in / 60)} minutes - - - ) : tokens.accessToken ? ( - - Authentication Successful - - You've successfully refreshed your token using device code flow. - - {tokens.tenantId && ( - - Tenant ID: {tokens.tenantId} - - )} - - ) : authError ? ( - - Authentication Error: {authError.errorCode} - {authError.errorMessage} - - Time: {authError.timestamp} - - - - - - ) : null} - - )} -
- ); -}; - -export default CIPPDeviceCodeButton; \ No newline at end of file diff --git a/src/components/CippComponents/CIPPM365OAuthButton.jsx b/src/components/CippComponents/CIPPM365OAuthButton.jsx index fbc742fe8ec8..504aa2404279 100644 --- a/src/components/CippComponents/CIPPM365OAuthButton.jsx +++ b/src/components/CippComponents/CIPPM365OAuthButton.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { Alert, Button, Typography, CircularProgress, Box } from '@mui/material' import { Microsoft, Login, Refresh } from '@mui/icons-material' import { ApiGetCall } from '../../api/ApiCall' @@ -17,6 +17,7 @@ export const CIPPM365OAuthButton = ({ autoStartDeviceLogon = false, validateServiceAccount = true, promptBeforeAuth = false, + disabled = false, }) => { const [authInProgress, setAuthInProgress] = useState(false) const [authError, setAuthError] = useState(null) @@ -40,6 +41,86 @@ export const CIPPM365OAuthButton = ({ waiting: true, }) + // Closing the device login window does not cancel anything - the device code stays + // valid server side until it expires and can be completed in any browser. So the + // watcher below never stops the poll; it only tracks whether the window is gone so + // the UI can offer a way back in instead of sitting on a disabled "Authenticating..." + // button for the full 15 minutes. + const devicePopupRef = useRef(null) + const devicePopupWatcherRef = useRef(null) + const devicePollIdRef = useRef(0) + const [devicePopupClosed, setDevicePopupClosed] = useState(false) + + const stopDevicePopupWatcher = () => { + if (devicePopupWatcherRef.current) { + clearInterval(devicePopupWatcherRef.current) + devicePopupWatcherRef.current = null + } + } + + const openDeviceLoginPopup = () => { + const width = 500 + const height = 600 + const left = window.screen.width / 2 - width / 2 + const top = window.screen.height / 2 - height / 2 + + const popup = window.open( + 'https://microsoft.com/devicelogin', + 'deviceLoginPopup', + `width=${width},height=${height},left=${left},top=${top}` + ) + + stopDevicePopupWatcher() + devicePopupRef.current = popup + + // A blocked popup is indistinguishable from a closed one as far as the user is + // concerned - both leave them with no window to sign in through. + if (!popup) { + setDevicePopupClosed(true) + return null + } + + setDevicePopupClosed(false) + devicePopupWatcherRef.current = setInterval(() => { + if (popup.closed) { + stopDevicePopupWatcher() + setDevicePopupClosed(true) + } + }, 1000) + + return popup + } + + const closeDeviceLoginPopup = () => { + stopDevicePopupWatcher() + const popup = devicePopupRef.current + if (popup && !popup.closed) { + popup.close() + } + devicePopupRef.current = null + setDevicePopupClosed(false) + } + + useEffect(() => stopDevicePopupWatcher, []) + + // Reopening the window is not offered: a user code is consumed the moment it is entered, + // so once someone has typed it in, re-entering the same code fails. Closing the window + // part way through a sign-in is therefore unrecoverable except with a fresh code. The + // poll is left running anyway, because the sign-in may still be getting finished at + // microsoft.com/devicelogin in another browser. + const canRestartDeviceLogin = useDeviceCode && authInProgress && devicePopupClosed + + const restartDeviceLogin = async () => { + // Supersede the in-flight poll before requesting a new code, or it would keep + // polling the old device_code alongside the new one. + devicePollIdRef.current += 1 + closeDeviceLoginPopup() + setAuthInProgress(false) + setAuthError(null) + setDeviceCodeInfo(null) + await retrieveDeviceCode() + } + const handleCloseError = () => { setAuthError(null) } @@ -125,29 +206,24 @@ export const CIPPM365OAuthButton = ({ const appId = applicationId || appIdInfo?.data?.applicationId || '1b730954-1685-4b74-9bfd-dac224a7b894' // Default to MS Graph Explorer app ID - // Open popup to device login page - const width = 500 - const height = 600 - const left = window.screen.width / 2 - width / 2 - const top = window.screen.height / 2 - height / 2 - - const popup = window.open( - 'https://microsoft.com/devicelogin', - 'deviceLoginPopup', - `width=${width},height=${height},left=${left},top=${top}` - ) + // Open popup to device login page. If it is closed or blocked the poll below keeps + // running - the button turns into "Reopen sign-in window" rather than locking up. + openDeviceLoginPopup() // Start polling for token const pollInterval = deviceCodeInfo.interval || 5 const expiresIn = deviceCodeInfo.expires_in || 900 const startTime = Date.now() + // Identifies this attempt. Starting over bumps the ref, which retires this poll + // rather than leaving it chasing a device code the user has abandoned. + const pollId = ++devicePollIdRef.current const pollForToken = async () => { + if (devicePollIdRef.current !== pollId) return + // Check if we've exceeded the expiration time if (Date.now() - startTime >= expiresIn * 1000) { - if (popup && !popup.closed) { - popup.close() - } + closeDeviceLoginPopup() setAuthError({ errorCode: 'timeout', errorMessage: 'Device code authentication timed out', @@ -158,17 +234,19 @@ export const CIPPM365OAuthButton = ({ } try { - // Poll for token using our API endpoint + // Poll for token using our API endpoint. The scope has to match the one the device + // code was issued for - omitting it here left the poll falling back to the API's + // default instead. const tokenResponse = await fetch( - `/api/ExecDeviceCodeLogon?operation=checkToken&clientId=${appId}&deviceCode=${deviceCodeInfo.device_code}` + `/api/ExecDeviceCodeLogon?operation=checkToken&clientId=${appId}&deviceCode=${ + deviceCodeInfo.device_code + }&scope=${encodeURIComponent(scope)}` ) const tokenData = await tokenResponse.json() if (tokenResponse.ok && tokenData.status === 'success') { // Successfully got token - if (popup && !popup.closed) { - popup.close() - } + closeDeviceLoginPopup() handleTokenResponse(tokenData) } else if ( tokenData.error === 'authorization_pending' || @@ -181,9 +259,7 @@ export const CIPPM365OAuthButton = ({ setTimeout(pollForToken, (pollInterval + 5) * 1000) } else { // Other error - if (popup && !popup.closed) { - popup.close() - } + closeDeviceLoginPopup() setAuthError({ errorCode: tokenData.error || 'token_error', errorMessage: tokenData.error_description || 'Failed to get token', @@ -296,7 +372,7 @@ export const CIPPM365OAuthButton = ({ const msalConfig = { auth: { clientId: appId, - authority: `https://login.microsoftonline.com/common`, + authority: `https://login.microsoftonline.com/organizations`, redirectUri: `${window.location.origin}/authredirect`, }, } @@ -306,34 +382,44 @@ export const CIPPM365OAuthButton = ({ scopes: [scope], } - // Generate PKCE code verifier and challenge - const generateCodeVerifier = () => { - const array = new Uint8Array(32) - window.crypto.getRandomValues(array) - return Array.from(array, (byte) => ('0' + (byte & 0xff).toString(16)).slice(-2)).join('') + // crypto.subtle is only exposed in a secure context. Without this guard an instance + // served over plain HTTP fails on the digest below with an opaque TypeError. + if (!window.crypto?.subtle) { + const error = { + errorCode: 'insecure_context', + errorMessage: + 'Authentication requires a secure context. Serve CIPP over HTTPS (or localhost) and try again.', + timestamp: new Date().toISOString(), + } + setAuthError(error) + if (onAuthError) onAuthError(error) + setAuthInProgress(false) + return } - const codeVerifier = generateCodeVerifier() - const codeChallenge = codeVerifier - const state = Math.random().toString(36).substring(2, 15) - const authUrl = - `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?` + - `client_id=${appId}` + - `&response_type=code` + - `&redirect_uri=${encodeURIComponent(window.location.origin)}/authredirect` + - `&scope=${encodeURIComponent(scope)}` + - `&code_challenge=${codeChallenge}` + - `&code_challenge_method=plain` + - `&state=${state}` + - `&prompt=select_account` + const base64UrlEncode = (bytes) => + btoa(String.fromCharCode(...new Uint8Array(bytes))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + + const randomUrlSafeString = (byteLength) => { + const array = new Uint8Array(byteLength) + window.crypto.getRandomValues(array) + return base64UrlEncode(array) + } const width = 500 const height = 600 const left = window.screen.width / 2 - width / 2 const top = window.screen.height / 2 - height / 2 + // Open the window before computing the challenge below. window.open only succeeds + // while the click's user activation is still live, and awaiting the SHA-256 digest + // first spends it - browsers then treat the call as an unsolicited popup and block + // it. Open a blank window synchronously and navigate it once the URL is ready. const popup = window.open( - authUrl, + '', 'msalAuthPopup', `width=${width},height=${height},left=${left},top=${top}` ) @@ -353,6 +439,36 @@ export const CIPPM365OAuthButton = ({ return } + // Generate PKCE code verifier and S256 challenge + const codeVerifier = randomUrlSafeString(32) + const codeChallenge = base64UrlEncode( + await window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier)) + ) + const state = randomUrlSafeString(16) + // prompt=login, not select_account: this flow mints the refresh token CIPP runs on, and + // Entra stamps that token with the authentication context of the sign-in that created it + // (including the protocol flow, which Conditional Access re-evaluates on every redemption). + // select_account can complete via SSO from an existing session - including the one the + // device code step establishes at microsoft.com/devicelogin in this same browser - which + // would carry a device-code-flow marker forward instead of clearing it. + // /organizations, not /common: CIPP-SAM is signInAudience AzureADMultipleOrgs, so it + // supports work and school accounts only. /common additionally advertises personal + // Microsoft accounts, letting someone pick one and fail later with a confusing error + // instead of being told up front that the account cannot be used. It also matches the + // authority the device code flow uses. + const authUrl = + `https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize?` + + `client_id=${appId}` + + `&response_type=code` + + `&redirect_uri=${encodeURIComponent(window.location.origin)}/authredirect` + + `&scope=${encodeURIComponent(scope)}` + + `&code_challenge=${codeChallenge}` + + `&code_challenge_method=S256` + + `&state=${state}` + + `&prompt=login` + + popup.location = authUrl + // Function to actually exchange the authorization code for tokens const handleAuthorizationCode = async (code, receivedState) => { // Verify the state parameter matches what we sent (security check) @@ -393,7 +509,7 @@ export const CIPPM365OAuthButton = ({ }, body: JSON.stringify({ tokenRequest, - tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + tokenUrl: 'https://login.microsoftonline.com/organizations/oauth2/v2.0/token', tenantId: appId, // Pass the tenant ID to retrieve the correct client secret }), }) @@ -401,10 +517,15 @@ export const CIPPM365OAuthButton = ({ // Parse the token response tokenData = await tokenResponse.json() - // Check if it's the AADSTS650051 error (service principal already exists) + // AADSTS650051: service principal already exists. + // AADSTS7000215: the client secret is not valid *yet*. The wizard mints a new + // secret on the previous step and arrives here seconds later, but Entra can take + // minutes to replicate it. Retrying covers the fast case; the message below covers + // the rest, since waiting it out would outlive the authorization code. if ( tokenData.error === 'invalid_client' && - tokenData.error_description?.includes('AADSTS650051') + (tokenData.error_description?.includes('AADSTS650051') || + tokenData.error_description?.includes('AADSTS7000215')) ) { retryCount++ if (retryCount <= maxRetries) { @@ -419,10 +540,12 @@ export const CIPPM365OAuthButton = ({ // Check if the response contains an error if (tokenData.error) { + const secretNotReady = tokenData.error_description?.includes('AADSTS7000215') const error = { errorCode: tokenData.error || 'token_error', - errorMessage: - tokenData.error_description || 'Failed to exchange authorization code for tokens', + errorMessage: secretNotReady + ? 'The application secret created for CIPP is not active yet. Microsoft can take several minutes to replicate a new secret across Entra ID. Wait a few minutes and run this step again - nothing needs to be recreated.' + : tokenData.error_description || 'Failed to exchange authorization code for tokens', timestamp: new Date().toISOString(), } setAuthError(error) @@ -551,10 +674,11 @@ export const CIPPM365OAuthButton = ({ // a short grace period before treating it as a cancellation. Without this, // closing the sign-in window left the button stuck on "Authenticating..." // until the 10-minute timeout. + let closeGraceTimer = null const popupWatcher = setInterval(() => { if (popup.closed) { clearInterval(popupWatcher) - setTimeout(() => { + closeGraceTimer = setTimeout(() => { if (!resultReceived) { cleanup() const error = { @@ -575,6 +699,12 @@ export const CIPPM365OAuthButton = ({ channel.close() clearTimeout(authTimeout) clearInterval(popupWatcher) + // The grace timer was previously left running. On the happy path - where the + // callback posts its result and then closes the popup - it would still be pending + // after cleanup, and if the user started another attempt inside that window it + // fired against the new one, clearing its progress state and reporting a + // cancellation for a sign-in that was still going. + clearTimeout(closeGraceTimer) } } @@ -633,7 +763,14 @@ export const CIPPM365OAuthButton = ({ - {authInProgress ? ( + {authInProgress && devicePopupClosed ? ( + <> + The sign-in window was closed. If you are still finishing at{' '} + microsoft.com/devicelogin in another browser, CIPP is still + waiting. If you had already entered the code, it cannot be used again - start + over below to get a new one. + + ) : authInProgress ? ( <> If the popup was blocked or you closed it, you can also go to{' '} microsoft.com/devicelogin manually and enter the code shown @@ -733,15 +870,21 @@ export const CIPPM365OAuthButton = ({ ) diff --git a/src/components/CippComponents/CippAutocomplete.jsx b/src/components/CippComponents/CippAutocomplete.jsx index 06a467903d17..5a1953382052 100644 --- a/src/components/CippComponents/CippAutocomplete.jsx +++ b/src/components/CippComponents/CippAutocomplete.jsx @@ -24,6 +24,11 @@ const MemoTextField = React.memo(function MemoTextField({ params, label, placeholder, + // Field-level required: asterisk on the label. HTML5 required is separate because + // Autocomplete (especially multiple) clears the input after selection — a static + // required on the input would falsely block submit even when chips/value exist. + required = false, + htmlRequired = false, // Autocomplete-specific props that must not be forwarded to TextField/DOM getOptionLabel, isOptionEqualToValue, @@ -43,11 +48,12 @@ const MemoTextField = React.memo(function MemoTextField({ label={label} placeholder={placeholder} {...otherProps} + required={htmlRequired} slotProps={{ inputLabel: { shrink: true, sx: { transition: 'none' }, - required: otherProps.required, + required, }, input: { ...InputProps, @@ -138,6 +144,13 @@ export const CippAutoComplete = React.forwardRef((props, ref) => { } }, [value, defaultValue]) + // Controlled value wins; otherwise use the onChange-tracked selection (FormComponent + // often drives via defaultValue + onChange rather than a controlled value prop). + const currentSelection = value !== undefined && value !== null ? value : internalValue + const hasSelection = multiple + ? Array.isArray(currentSelection) && currentSelection.length > 0 + : currentSelection != null && currentSelection !== '' + // This is our paginated call const actionGetRequest = ApiGetCallWithPagination({ ...getRequestInfo, @@ -611,6 +624,7 @@ export const CippAutoComplete = React.forwardRef((props, ref) => { label={label} placeholder={placeholder} required={required} + htmlRequired={required && !hasSelection} {...other} /> {api?.url && api?.showRefresh && ( diff --git a/src/components/CippPdf/ReportDocument.jsx b/src/components/CippPdf/ReportDocument.jsx index c9f104aad7ba..5ea627587a0b 100644 --- a/src/components/CippPdf/ReportDocument.jsx +++ b/src/components/CippPdf/ReportDocument.jsx @@ -55,8 +55,7 @@ export const ReportDocument = ({ // The report's own footer wording, used when branding configures none. footerLabel, - // Resolved CIPP variables, from `useReportVariables`. Without them a footer configured with - // `%cippurl%` or a custom variable ships with the token still written in it. + // Resolved CIPP variables, from `useReportVariables`. variables: cippVariables, size = DEFAULT_PAGE_SETUP.size, @@ -73,10 +72,8 @@ export const ReportDocument = ({ generatedOn ?? new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) - // What every `%variable%` resolves to anywhere in this report. CIPP's own come first and the - // report's three override them: `%reportname%` and `%reportdate%` exist nowhere else, and this - // report's subject is the authority on `%tenantname%` — it still resolves before the fetch lands, - // which a footer that has always worked should not have to wait for. + // What every `%variable%` resolves to in this report. The report's own three override CIPP's, + // so `%tenantname%` still resolves before the variables fetch lands. const variables = { ...cippVariables, tenantname: tenantName || 'Organization', diff --git a/src/components/CippPdf/reportPdfPrimitives.jsx b/src/components/CippPdf/reportPdfPrimitives.jsx index 86b292623f1e..c671983dba19 100644 --- a/src/components/CippPdf/reportPdfPrimitives.jsx +++ b/src/components/CippPdf/reportPdfPrimitives.jsx @@ -173,10 +173,7 @@ export const ReportPage = ({ }) => ( {children} - {/* Last, so it paints over the content rather than under it. Underneath, anything with a solid - background — a chart card, a stat tile, a table header — hid it completely, which is how a - page that did carry a watermark still looked like it did not. At 8% it reads as a wash over - the page and leaves everything below it legible. */} + {/* Last, so it paints over the content. Underneath, any solid background hides it entirely. */} ) diff --git a/src/components/CippPdf/reportTheme.js b/src/components/CippPdf/reportTheme.js index 6cc5439c2c13..6e288ae766c7 100644 --- a/src/components/CippPdf/reportTheme.js +++ b/src/components/CippPdf/reportTheme.js @@ -300,10 +300,8 @@ export const buildPalette = (branding, { primary, secondary }) => { * footer might want — `%tenantname%` foremost — is already a CIPP variable, so it is not restated * here. The `report` prefix keeps these clear of the reserved names in Get-CIPPTextReplacement. * - * A PDF is rendered in the browser, so Get-CIPPTextReplacement never sees this text and cannot fill - * CIPP's own variables in it. `useReportVariables` reads their resolved values back out of - * ListCustomVariables and hands them to the report, which is what makes `%cippurl%` in a footer - * print the URL rather than the word. Being a CIPP variable is about where it is documented and + * A PDF renders in the browser, so Get-CIPPTextReplacement never sees this text. `useReportVariables` + * supplies the resolved values instead. Being a CIPP variable is about where it is documented and * offered, not about who substitutes it. */ export const REPORT_VARIABLES = [ @@ -318,9 +316,8 @@ export const REPORT_VARIABLES = [ * and an unknown token is left as written rather than blanked — that is what tells whoever * configured it that they mistyped, instead of silently swallowing it. * - * This runs in the browser because that is where the PDF is rendered, so it is given the values - * rather than looking them up: the report's own tokens plus whatever `useReportVariables` resolved - * out of CIPP for the tenant. + * Given the values rather than looking them up: the report's own tokens plus whatever + * `useReportVariables` resolved for the tenant. */ export const applyReportVariables = (template, variables = {}) => { if (!template) return '' diff --git a/src/components/CippPdf/useBrandingSettings.js b/src/components/CippPdf/useBrandingSettings.js index 45006095b7ba..ff62ffded424 100644 --- a/src/components/CippPdf/useBrandingSettings.js +++ b/src/components/CippPdf/useBrandingSettings.js @@ -3,11 +3,8 @@ import { ApiGetCall } from '../../api/ApiCall' import { DEFAULT_COVER_STOCK } from './resolveCoverImage' /** - * The branding a report is drawn with, before any preset is applied. - * - * What every consumer sees when branding has not loaded yet — or cannot be read at all. A report - * rendered in CIPP's own colours is worth far more than one that fails to render, so nothing here - * waits on the fetch. + * Branding used before the fetch lands, or when it cannot be read at all. Reports render in + * CIPP's own colours rather than waiting. */ export const DEFAULT_BRANDING = Object.freeze({ colour: '#F77F00', @@ -32,32 +29,20 @@ export const DEFAULT_BRANDING = Object.freeze({ }) /** - * The react-query keys branding is cached under. Invalidate both after any branding write — - * `relatedQueryKeys: ['BrandingSettings*']` covers them with one entry. - * - * Two keys because they are two different payloads: the gallery form carries every uploaded logo - * and cover inline, which is megabytes, and only the settings page needs it. + * Cache keys for the two payloads: with and without the upload galleries. Invalidate both after a + * branding write with `relatedQueryKeys: ['BrandingSettings*']`. */ export const BRANDING_QUERY_KEY = 'BrandingSettings' export const BRANDING_GALLERY_QUERY_KEY = 'BrandingSettings-gallery' /** - * Read the report branding. - * - * Branding used to live on `useSettings().customBranding`, filled in by ListUserSettings. That put - * every uploaded cover image — inline base64 data URLs, megabytes of them — into a response fetched - * on every page load, for the benefit of the handful of screens that draw a PDF. It also meant the - * branding a report used and the branding the settings page was editing were the same mutable blob - * of client state, kept in step by an effect. - * - * Now it is a request, made by the components that need it, cached by react-query and shared - * between them. `relatedQueryKeys: [BRANDING_QUERY_KEY]` on a write is what refreshes it. + * Read the report branding. Replaces `useSettings().customBranding`, which carried inline images + * on every page load and kept report and settings state in the same mutable blob. */ export const useBrandingSettings = ({ waiting = true, includeGallery = false } = {}) => { const branding = ApiGetCall({ url: '/api/ListBrandingSettings', - // Only the settings page asks for the galleries. A report needs the logo and cover that are - // selected, and those come back either way. + // Only the settings page needs the galleries; the selected logo and cover come back either way. data: includeGallery ? { includeGallery: true } : undefined, queryKey: includeGallery ? BRANDING_GALLERY_QUERY_KEY : BRANDING_QUERY_KEY, waiting, @@ -65,7 +50,7 @@ export const useBrandingSettings = ({ waiting = true, includeGallery = false } = return useMemo(() => { const data = branding.data - // The endpoint answers 200 with no body when branding cannot be read, so the app still renders. + // The endpoint answers 200 with no body when branding cannot be read. if (!data || typeof data !== 'object' || Array.isArray(data)) return DEFAULT_BRANDING return data }, [branding.data]) diff --git a/src/components/CippPdf/useReportVariables.js b/src/components/CippPdf/useReportVariables.js index b0350f70df55..8d19f6979037 100644 --- a/src/components/CippPdf/useReportVariables.js +++ b/src/components/CippPdf/useReportVariables.js @@ -6,20 +6,12 @@ import { useSettings } from '../../hooks/use-settings' const EMPTY = {} /** - * The resolved values of every CIPP variable, for substitution into report footers and watermarks. + * Resolved values of every CIPP variable, for substitution into report footers and watermarks by + * `applyReportVariables`. A PDF renders in the browser and never passes through + * Get-CIPPTextReplacement, so the values are read from ListCustomVariables instead. * - * A report's footer is written by an operator on the branding page, where the `%` picker offers the - * whole CIPP variable vocabulary — `%cippurl%`, `%tenantid%`, custom variables, all of it. Those are - * normally filled in by Get-CIPPTextReplacement on the server, but a PDF is rendered in the browser - * and never passes through it, so a footer that used anything beyond the report's own tokens shipped - * with the token still written in it. - * - * This is the missing half: the values come from ListCustomVariables, which resolves them for the - * tenant, and `applyReportVariables` does the substitution at render time. - * - * Fetched here rather than inside the document because a report is rendered by react-pdf's own - * reconciler, outside the React tree — there is no query client in there to hook into. The values - * have to arrive as data. + * Fetched by the caller rather than inside the document: react-pdf renders through its own + * reconciler, outside the React tree, where there is no query client. */ export const useReportVariables = (tenantFilter) => { const currentTenant = useSettings()?.currentTenant @@ -42,9 +34,8 @@ export const useReportVariables = (tenantFilter) => { const resolved = {} for (const variable of results) { - // A variable with no value is one CIPP cannot fill — the system tokens expanded on an - // endpoint, mainly. Leaving it out means the token stays written in the footer, which is what - // tells whoever configured it that it does not resolve here. + // Valueless variables (mostly system tokens, expanded on an endpoint) are left out, so the + // token stays written in the footer rather than resolving to nothing. if (variable?.Name && variable.Value !== null && variable.Value !== undefined && variable.Value !== '') { resolved[variable.Name] = variable.Value } diff --git a/src/components/CippSettings/CippBrandingSettings.jsx b/src/components/CippSettings/CippBrandingSettings.jsx index de3f9f454e69..c798c9afbcf0 100644 --- a/src/components/CippSettings/CippBrandingSettings.jsx +++ b/src/components/CippSettings/CippBrandingSettings.jsx @@ -222,9 +222,8 @@ const GalleryTile = ({ const CippBrandingSettings = () => { const settings = useSettings(); - // Read through ApiGetCall rather than useBrandingSettings so this page can see when the fetch - // landed: the sync effect below has to run on a *new* server payload, not on every render. - // Same url and queryKey, so it is the same cache entry every report reads. + // Read through ApiGetCall rather than useBrandingSettings so the sync effect below can key on + // when the fetch landed. Same cache entry either way. const brandingQuery = ApiGetCall({ url: "/api/ListBrandingSettings", data: { includeGallery: true }, @@ -415,10 +414,6 @@ const CippBrandingSettings = () => { if (coversHydrated || logosHydrated) { setCoversReady(true); } - // Branding used to be a mutable client blob on the settings object, so this had to list every - // field that might have changed underneath it — and compare the arrays by hand, because their - // identity changed on every render. A query has one answer to "is this a new payload from the - // server", which is the only question this effect was ever asking. // eslint-disable-next-line react-hooks/exhaustive-deps -- sync when server branding payload changes }, [activePresetId, uploadPending, brandingQuery.isSuccess, brandingQuery.dataUpdatedAt]); diff --git a/src/components/CippWizard/CippSAMDeploy.jsx b/src/components/CippWizard/CippSAMDeploy.jsx index d38d0f66ddf2..8507fac4881a 100644 --- a/src/components/CippWizard/CippSAMDeploy.jsx +++ b/src/components/CippWizard/CippSAMDeploy.jsx @@ -100,6 +100,15 @@ export const CippSAMDeploy = (props) => { Multi-factor authentication enabled for the CIPP Service Account, with no trusted locations or other exclusions. +
  • + Device code sign-in permitted in your partner tenant. Security defaults and Conditional + Access authentication flow policies can block it, which will stop this step from + completing. +
  • + + + This step only creates the CIPP-SAM application registration. The token CIPP runs on is + created by the sign-in on the next step. {authStatus.error && ( diff --git a/src/components/CippWizard/CippTenantModeDeploy.jsx b/src/components/CippWizard/CippTenantModeDeploy.jsx index d0736b8c5c2e..b31df79683d6 100644 --- a/src/components/CippWizard/CippTenantModeDeploy.jsx +++ b/src/components/CippWizard/CippTenantModeDeploy.jsx @@ -1,5 +1,6 @@ import { useEffect } from "react"; import { + Alert, Stack, Box, Typography, @@ -35,6 +36,33 @@ export const CippTenantModeDeploy = (props) => { waiting: true, }); + // The application step mints a client secret and this step uses it moments later, but Entra + // can take minutes to activate a new secret. Poll until it is usable so the wait happens + // here, rather than the sign-in appearing to work and then failing on the token exchange + // with an "invalid client secret" that looks like the app was created wrong. + const samSecret = ApiGetCall({ + url: `/api/ExecSamSecretStatus`, + queryKey: "samSecretStatus", + waiting: true, + staleTime: 0, + }); + const samSecretReady = samSecret.data?.ready === true; + const samSecretPropagating = samSecret.data?.reason === "propagating"; + const { + isSuccess: samSecretLoaded, + dataUpdatedAt: samSecretUpdatedAt, + refetch: refetchSamSecret, + } = samSecret; + + // Re-check on a timer rather than a fixed refetchInterval so polling stops once the secret + // is usable - there is nothing left to wait for at that point. + useEffect(() => { + if (samSecretLoaded && !samSecretReady) { + const timer = setTimeout(() => refetchSamSecret(), 15000); + return () => clearTimeout(timer); + } + }, [samSecretLoaded, samSecretUpdatedAt, samSecretReady, refetchSamSecret]); + useEffect(() => { if (updateRefreshToken.isSuccess) { formControl.setValue("GDAPAuth", true); @@ -201,8 +229,24 @@ export const CippTenantModeDeploy = (props) => { )} + {samSecretLoaded && !samSecretReady && ( + + {samSecretPropagating ? ( + <> + Waiting for Microsoft to activate the application secret created in the previous + step. Signing in before it is active fails with an invalid client secret error, so + this step unlocks on its own once it is ready - usually within a few minutes. + Nothing needs to be recreated. + + ) : ( + samSecret.data?.message + )} + + )} + { const updatedTokenData = { ...tokenData, diff --git a/src/components/ReleaseNotesDialog.js b/src/components/ReleaseNotesDialog.js index dbdf649ed410..1e7f3e166e1c 100644 --- a/src/components/ReleaseNotesDialog.js +++ b/src/components/ReleaseNotesDialog.js @@ -79,16 +79,22 @@ const deleteCookie = (name) => { document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax;${secureFlag()}` } +// Hotfix and maintenance builds publish their own GitHub release (v10.8.1, v10.8.2, ...), so the +// running build's exact tag is both what we show and what we remember as dismissed. Collapsing +// patch releases back to vX.Y.0 here left the dismissal cookie - which stores the tag that was +// actually released - permanently unmatchable, so the dialog reopened on every page load. +// baseTag survives only as a display fallback for builds whose exact tag has no release +// (nightly, local, or a version bumped ahead of the tag being published). const buildReleaseMetadata = (version) => { - const [major = '0', minor = '0', patch = '0'] = String(version).split('.') + const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(version ?? '')) + const [major, minor, patch] = match ? match.slice(1) : ['0', '0', '0'] const currentTag = `v${major}.${minor}.${patch}` - const baseTag = `v${major}.${minor}.0` - const tagToUse = patch === '0' ? currentTag : baseTag return { currentTag, - releaseTag: tagToUse, - releaseUrl: `https://github.com/${RELEASE_OWNER}/${RELEASE_REPO}/releases/tag/${tagToUse}`, + baseTag: `v${major}.${minor}.0`, + releaseTag: currentTag, + releaseUrl: `https://github.com/${RELEASE_OWNER}/${RELEASE_REPO}/releases/tag/${currentTag}`, } } @@ -194,12 +200,13 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { if (!hasSelected) { const fallbackRelease = releaseCatalog.find((release) => release.releaseTag === releaseMeta.releaseTag) || + releaseCatalog.find((release) => release.releaseTag === releaseMeta.baseTag) || releaseCatalog[0] if (fallbackRelease) { setSelectedReleaseTag(fallbackRelease.releaseTag) } } - }, [releaseCatalog, selectedReleaseTag, releaseMeta.releaseTag]) + }, [releaseCatalog, selectedReleaseTag, releaseMeta]) const releaseOptions = useMemo(() => { const mapped = releaseCatalog.map((release) => { @@ -267,15 +274,17 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { return ( releaseCatalog.find((release) => release.releaseTag === selectedReleaseTag) || releaseCatalog.find((release) => release.releaseTag === releaseMeta.releaseTag) || + releaseCatalog.find((release) => release.releaseTag === releaseMeta.baseTag) || null ) - }, [releaseCatalog, selectedReleaseTag, releaseMeta.releaseTag]) + }, [releaseCatalog, selectedReleaseTag, releaseMeta]) const handleDismissUntilNextRelease = () => { - const newestRelease = releaseCatalog[0] - const tagToStore = newestRelease?.releaseTag ?? newestRelease?.tagName ?? releaseMeta.releaseTag + // Store the same tag the eligibility check reads back - the tag of the build being run, not + // the newest tag on GitHub. Those differ for anyone not on the very latest release, and a + // cookie that can never match means "don't show until next release" never suppresses anything. window.localStorage.removeItem(RELEASE_PERMANENT_HIDE_KEY) - setCookie(RELEASE_COOKIE_KEY, tagToStore) + setCookie(RELEASE_COOKIE_KEY, releaseMeta.releaseTag) setOpen(false) setIsExpanded(false) setManualOpenRequested(false) diff --git a/src/contexts/settings-context.js b/src/contexts/settings-context.js index e285284317bf..c4356e8187b3 100644 --- a/src/contexts/settings-context.js +++ b/src/contexts/settings-context.js @@ -64,10 +64,9 @@ const deleteSettings = () => { }; /** - * Branding is no longer client settings — it is a request, cached by react-query under - * `BRANDING_QUERY_KEY` and read via `useBrandingSettings`. Anything a previous version of CIPP - * persisted here is dropped on load rather than migrated: it is a stale copy of server state, and - * its image payloads are what used to blow the localStorage quota once covers were uploaded. + * Branding is server state now, read via `useBrandingSettings`. Anything a previous version + * persisted here is dropped on load rather than migrated - it is a stale copy, and its image + * payloads used to exhaust the localStorage quota. */ const stripPersistedBrandingBlobs = (settings) => { if (!settings || typeof settings !== "object" || !("customBranding" in settings)) { diff --git a/src/pages/cipp/settings/partner-webhooks.js b/src/pages/cipp/settings/partner-webhooks.js index 38d681883a1e..e5ae63287d29 100644 --- a/src/pages/cipp/settings/partner-webhooks.js +++ b/src/pages/cipp/settings/partner-webhooks.js @@ -45,6 +45,10 @@ const Page = () => { const subscription = listSubscription?.data?.Results; const expectedWebhookUrl = subscription?.expectedWebhookUrl; + // The backend resolves the expected URL from the custom domain bound to the instance, not from + // the host this page was loaded on, so surface which one it picked when there is more than one. + const customDomains = subscription?.customDomains ?? []; + const hasMultipleCustomDomains = customDomains.length > 1; // Compared case-insensitively to match the backend, which uses PowerShell's -ne const webhookUrlIsStale = !!expectedWebhookUrl && @@ -180,11 +184,19 @@ const Page = () => { {webhookUrlIsStale && ( - This subscription points at a different URL than the one you are using now. - Save the settings below to re-register it against{" "} + This subscription points at a different URL than the one this instance is + published on. Save the settings below to re-register it against{" "} {expectedWebhookUrl}. )} + {hasMultipleCustomDomains && ( + + This instance has {customDomains.length} custom domains bound ( + {customDomains.join(", ")}). CIPP uses the first one,{" "} + {subscription?.instanceHostname}, for webhook registrations + and notification links. + + )} ), sx: { pl: 0 }, diff --git a/src/pages/identity/reports/inactive-users-report/index.js b/src/pages/identity/reports/inactive-users-report/index.js index 8e8e7a0edc50..3ed0ef6c286f 100644 --- a/src/pages/identity/reports/inactive-users-report/index.js +++ b/src/pages/identity/reports/inactive-users-report/index.js @@ -20,14 +20,14 @@ const Page = () => { const actions = [ { label: "View User", - link: "/identity/administration/users/user?userId=[azureAdUserId]", + link: "/identity/administration/users/user?userId=[azureAdUserId]&tenantFilter=[tenantId]", multiPost: false, icon: , color: "success", }, { label: "Edit User", - link: "/identity/administration/users/user/edit?userId=[azureAdUserId]", + link: "/identity/administration/users/user/edit?userId=[azureAdUserId]&tenantFilter=[tenantId]", icon: , color: "success", target: "_self", diff --git a/src/pages/tenant/administration/tenants/index.js b/src/pages/tenant/administration/tenants/index.js index b6539a5ad323..9eb110355cd6 100644 --- a/src/pages/tenant/administration/tenants/index.js +++ b/src/pages/tenant/administration/tenants/index.js @@ -10,6 +10,7 @@ const Page = () => { const simpleColumns = [ "displayName", "defaultDomainName", + "tenantGroups", "portal_m365", "portal_exchange", "portal_entra", diff --git a/src/pages/tenant/baselines/alignment/index.js b/src/pages/tenant/baselines/alignment/index.js index d50f6248e407..a595d8ec2333 100644 --- a/src/pages/tenant/baselines/alignment/index.js +++ b/src/pages/tenant/baselines/alignment/index.js @@ -10,6 +10,7 @@ import { Divider, Link, Stack, + TextField, ToggleButton, ToggleButtonGroup, Tooltip, @@ -50,6 +51,7 @@ import { LayersClear, PlayArrow, RemoveCircle, + Search, TaskAlt, Tune, Visibility, @@ -79,6 +81,7 @@ import { useSettings } from '../../../../hooks/use-settings' import { ApiGetCall } from '../../../../api/ApiCall' import { parseCippDate } from '../../../../utils/parse-cipp-date' import { CippOffCanvas } from '../../../../components/CippComponents/CippOffCanvas' +import { CippAutoComplete } from '../../../../components/CippComponents/CippAutocomplete' import CippJsonView from '../../../../components/CippFormPages/CippJSONView' const deviationColors = { @@ -281,6 +284,9 @@ const runModeLabels = { run: 'Full run', compare: 'Compare', oneoff: 'One-off remediation', + triage: 'Operator action', + stage: 'Stage change', + delete: 'Deletion', } // Timeline dot/chip styling per run outcome, mirroring the manage-tenant history page. @@ -309,10 +315,61 @@ const outcomeTimeline = { icon: , label: 'Skipped - No License', }, + // Operator/system audit events (triage verdicts, overrides, stage changes, + // deletions carried out for denied deviations). + Accepted: { color: 'info', chipColor: 'info', icon: }, + 'Property Accepted': { color: 'info', chipColor: 'info', icon: }, + 'Denied - Remediation Ordered': { + color: 'warning', + chipColor: 'warning', + icon: , + }, + 'Denied - Delete Ordered': { + color: 'warning', + chipColor: 'warning', + icon: , + }, + 'Property Denied': { + color: 'warning', + chipColor: 'warning', + icon: , + }, + 'Triage Cleared': { color: 'grey', chipColor: 'default', icon: }, + 'Property Triage Cleared': { + color: 'grey', + chipColor: 'default', + icon: , + }, + 'Task Completed': { + color: 'success', + chipColor: 'success', + icon: , + }, + 'Override Created': { color: 'info', chipColor: 'info', icon: }, + 'Override Removed': { + color: 'grey', + chipColor: 'default', + icon: , + }, + 'Stage Advanced': { + color: 'primary', + chipColor: 'primary', + icon: , + }, + Deleted: { color: 'error', chipColor: 'error', icon: }, + 'Delete Failed': { + color: 'error', + chipColor: 'error', + icon: , + }, } -// One readable sentence per run event for the historic timeline. +// One readable sentence per run event for the historic timeline. Operator and +// system events carry their own story in `detail`; run events derive one here. const historyEventMessage = (event) => { + if (event.detail) { + return `"${event.standardLabel}" - ${event.detail}` + } switch (event.outcome) { case 'Remediated': return `Successfully changed "${event.standardLabel}" to the expected configuration` @@ -408,18 +465,43 @@ const Page = () => { const denyPathDialog = useDialog() const [removeOverrideTarget, setRemoveOverrideTarget] = useState(null) const removeOverrideDialog = useDialog() + // Filtering re-orders the timeline, so expansion state keys on stable event/run + // identity rather than render index. const [expandedEvents, setExpandedEvents] = useState(new Set()) - const toggleEventExpansion = (index) => { + const toggleEventExpansion = (eventKey) => { setExpandedEvents((prev) => { const next = new Set(prev) - if (next.has(index)) { - next.delete(index) + if (next.has(eventKey)) { + next.delete(eventKey) + } else { + next.add(eventKey) + } + return next + }) + } + const [expandedRuns, setExpandedRuns] = useState(new Set()) + const toggleRunExpansion = (runKey) => { + setExpandedRuns((prev) => { + const next = new Set(prev) + if (next.has(runKey)) { + next.delete(runKey) } else { - next.add(index) + next.add(runKey) } return next }) } + const [historyFilters, setHistoryFilters] = useState({ + standard: [], + outcome: [], + mode: [], + search: '', + }) + const [historyLimit, setHistoryLimit] = useState(50) + const setHistoryFilter = (name, value) => { + setHistoryFilters((prev) => ({ ...prev, [name]: value })) + setHistoryLimit(50) + } const isTenantView = viewMode === 'tenant' const isTemplateView = viewMode === 'template' @@ -1515,14 +1597,8 @@ const Page = () => { { {run.triggeredBy} {run.remediated ? ', remediated' : ''} + {run.detail && ( + + {run.detail} + + )} ))} + ) @@ -2276,11 +2378,73 @@ const Page = () => { ) - // Historic view: the tenant's run events on an activity timeline (same pattern as the - // manage-tenant history page). Each event carries its run GUID; View Logs opens the - // Baselines log drawer filtered to exactly that run's entries. + // Historic view: every recorded baseline event for the tenant on an activity + // timeline (same pattern as the manage-tenant history page). Engine runs touch + // many standards under one run GUID, so those group into a collapsible summary + // entry; operator events (triage, overrides, stage changes, deletions) stand on + // their own. View Logs opens the Baselines log drawer filtered to one run. if (viewMode === 'history') { const historyEvents = historyApi.data?.events ?? [] + const standardOptions = [ + ...new Set(historyEvents.map((event) => event.standardLabel)), + ] + .filter(Boolean) + .sort() + .map((value) => ({ label: value, value })) + const outcomeOptions = [ + ...new Set(historyEvents.map((event) => event.outcome)), + ] + .filter(Boolean) + .sort() + .map((value) => ({ + label: outcomeTimeline[value]?.label ?? value, + value, + })) + const modeOptions = [...new Set(historyEvents.map((event) => event.mode))] + .filter(Boolean) + .map((value) => ({ label: runModeLabels[value] ?? value, value })) + const searchTerm = historyFilters.search.trim().toLowerCase() + const filteredEvents = historyEvents.filter( + (event) => + (historyFilters.standard.length === 0 || + historyFilters.standard.includes(event.standardLabel)) && + (historyFilters.outcome.length === 0 || + historyFilters.outcome.includes(event.outcome)) && + (historyFilters.mode.length === 0 || + historyFilters.mode.includes(event.mode)) && + (!searchTerm || + `${event.standardLabel} ${event.outcome} ${event.detail ?? ''} ${event.triggeredBy}` + .toLowerCase() + .includes(searchTerm)) + ) + // Group by run GUID (newest-first order preserved); multi-event groups render + // as one collapsible summary. Flattening to render rows up front lets the + // timeline connector stop at the true last item. + const runGroups = [] + const groupIndex = new Map() + for (const event of filteredEvents) { + const key = String(event.runId ?? 'unknown') + if (groupIndex.has(key)) { + runGroups[groupIndex.get(key)].events.push(event) + } else { + groupIndex.set(key, runGroups.length) + runGroups.push({ runId: key, events: [event] }) + } + } + const visibleGroups = runGroups.slice(0, historyLimit) + const renderRows = [] + for (const group of visibleGroups) { + if (group.events.length === 1) { + renderRows.push({ type: 'event', event: group.events[0] }) + } else { + renderRows.push({ type: 'group', group }) + if (expandedRuns.has(group.runId)) { + for (const event of group.events) { + renderRows.push({ type: 'event', event }) + } + } + } + } return ( <> @@ -2301,9 +2465,95 @@ const Page = () => { /> - This timeline shows every recorded baseline run event for{' '} - {tenant.displayName}. + This timeline shows every recorded baseline event for{' '} + {tenant.displayName} - runs, operator decisions, stage changes, + and deletions. + + + + setHistoryFilter('search', event.target.value) + } + autoComplete="off" + placeholder="Search by standard, outcome, or operator..." + InputProps={{ + startAdornment: ( + + ), + }} + /> + + + ({ + label: value, + value, + }))} + onChange={(newValue) => + setHistoryFilter( + 'standard', + Array.isArray(newValue) + ? newValue.map((option) => option.value) + : [] + ) + } + /> + + + ({ + label: outcomeTimeline[value]?.label ?? value, + value, + }))} + onChange={(newValue) => + setHistoryFilter( + 'outcome', + Array.isArray(newValue) + ? newValue.map((option) => option.value) + : [] + ) + } + /> + + + ({ + label: runModeLabels[value] ?? value, + value, + }))} + onChange={(newValue) => + setHistoryFilter( + 'mode', + Array.isArray(newValue) + ? newValue.map((option) => option.value) + : [] + ) + } + /> + + {historyApi.isFetching && ( @@ -2315,7 +2565,14 @@ const Page = () => { first. )} - {historyEvents.length > 0 && ( + {!historyApi.isFetching && + historyEvents.length > 0 && + filteredEvents.length === 0 && ( + + No events match the current filters. + + )} + {renderRows.length > 0 && ( { [`& .MuiTimelineContent-root`]: { flex: 0.8 }, }} > - {historyEvents.map((event, index) => { + {renderRows.map((row, index) => { + // Collapsed engine run: one summary entry with per-outcome + // counts; expanding reveals the individual standards below. + if (row.type === 'group') { + const group = row.group + const first = group.events[0] + const groupDate = parseCippDate(first.timestamp) + const outcomeCounts = {} + for (const groupEvent of group.events) { + outcomeCounts[groupEvent.outcome] = + (outcomeCounts[groupEvent.outcome] ?? 0) + 1 + } + const severityRank = { + error: 4, + warning: 3, + info: 2, + success: 1, + } + const dotColor = group.events.reduce( + (worst, groupEvent) => { + const color = + outcomeTimeline[groupEvent.outcome]?.color ?? + 'grey' + return (severityRank[color] ?? 0) > + (severityRank[worst] ?? 0) + ? color + : worst + }, + 'grey' + ) + const isOpen = expandedRuns.has(group.runId) + const alertedCount = group.events.filter( + (groupEvent) => groupEvent.alerted + ).length + return ( + + + + {groupDate.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + })} + + + {groupDate.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + })} + + + + + {first.mode === 'compare' ? ( + + ) : ( + + )} + + {index < renderRows.length - 1 && ( + + )} + + + + + + + + + {Object.entries(outcomeCounts).map( + ([outcome, count]) => ( + + ) + )} + {alertedCount > 0 && ( + + )} + + + Processed {group.events.length} standards in + this run + + + + toggleRunExpansion(group.runId) + } + sx={{ + textAlign: 'left', + fontSize: '0.75rem', + }} + > + {isOpen + ? 'Hide the individual standards' + : `View all ${group.events.length} standards`} + + + + + Triggered by {first.triggeredBy} + + + + + ) + } + const event = row.event const timelineConfig = outcomeTimeline[event.outcome] ?? { color: 'grey', chipColor: 'default', icon: , } const eventDate = parseCippDate(event.timestamp) - const isExpanded = expandedEvents.has(index) + const eventKey = `${event.runId}-${event.standardName}-${event.outcome}-${event.timestamp}` + const isExpanded = expandedEvents.has(eventKey) const diffEntries = event.diff ? Array.isArray(event.diff) ? event.diff : [event.diff] : [] return ( - + { > {timelineConfig.icon} - {index < historyEvents.length - 1 && ( + {index < renderRows.length - 1 && ( )} @@ -2415,6 +2854,15 @@ const Page = () => { sx={{ fontSize: '0.7rem', height: 20 }} /> + {event.alerted && ( + + )} { toggleEventExpansion(index)} + onClick={() => + toggleEventExpansion(eventKey) + } sx={{ textAlign: 'left', fontSize: '0.75rem', @@ -2505,6 +2955,15 @@ const Page = () => { )} + {runGroups.length > historyLimit && ( + + )} {dialogs} diff --git a/src/pages/tenant/baselines/templates/index.js b/src/pages/tenant/baselines/templates/index.js index 9fd321c5f0c5..2c28006c27f1 100644 --- a/src/pages/tenant/baselines/templates/index.js +++ b/src/pages/tenant/baselines/templates/index.js @@ -16,6 +16,7 @@ import { CopyAll, Delete, Edit, + GitHub, PlayArrow, } from '@mui/icons-material' import { Layout as DashboardLayout } from '../../../../layouts/index.js' @@ -27,6 +28,7 @@ import { CippOffCanvas } from '../../../../components/CippComponents/CippOffCanv import { CippTemplateCatalog } from '../../../../components/CippComponents/CippTemplateCatalog' import { describeStageConditions } from '../../../../components/CippBaselines/CippBaselineWhatIfReport' import { parseCippDate } from '../../../../utils/parse-cipp-date' +import { ApiGetCall } from '../../../../api/ApiCall' // The API serializes single-element arrays as a bare object; the selector needs a real array. const asOptionArray = (value) => @@ -37,6 +39,10 @@ const asOptionArray = (value) => const Page = () => { const pageTitle = 'Baselines' const [catalogVisible, setCatalogVisible] = useState(false) + const integrations = ApiGetCall({ + url: '/api/ListExtensionsConfig', + queryKey: 'Integrations', + }) const actions = [ { @@ -76,6 +82,43 @@ const Page = () => { multiPost: false, relatedQueryKeys: ['ListBaseline*'], }, + { + label: 'Save to GitHub', + type: 'POST', + url: '/api/ExecCommunityRepo', + icon: , + data: { Action: 'UploadBaseline', GUID: 'GUID' }, + fields: [ + { + label: 'Repository', + name: 'FullName', + type: 'select', + api: { + url: '/api/ListCommunityRepos', + data: { WriteAccess: true }, + queryKey: 'CommunityRepos-Write', + dataKey: 'Results', + valueField: 'FullName', + labelField: 'FullName', + }, + multiple: false, + creatable: false, + required: true, + }, + { + label: 'Commit Message', + name: 'Message', + type: 'textField', + multiline: true, + required: true, + rows: 4, + }, + ], + confirmText: + 'Save [templateName] to the selected repository? This uploads the baseline AND every CA/Intune template it references as separate files. Template packages are expanded to their current members, and tenant assignments are replaced with a placeholder.', + condition: () => + integrations.isSuccess && integrations?.data?.GitHub?.Enabled, + }, { label: 'Delete Baseline', type: 'POST', diff --git a/src/pages/tools/community-repos/index.js b/src/pages/tools/community-repos/index.js index 72494f4997b7..acc83ad832e9 100644 --- a/src/pages/tools/community-repos/index.js +++ b/src/pages/tools/community-repos/index.js @@ -51,6 +51,7 @@ const typeOptions = [ { label: "Intune Policy", value: "IntuneTemplate" }, { label: "Conditional Access", value: "CATemplate" }, { label: "Standards", value: "StandardsTemplateV2" }, + { label: "Baseline", value: "BaselineTemplate" }, { label: "Report Builder", value: "ReportBuilderTemplate" }, { label: "Group", value: "GroupTemplate" }, { label: "Custom Test", value: "CustomTest" }, diff --git a/src/utils/get-cipp-formatting.js b/src/utils/get-cipp-formatting.js index 604b3dea9325..cbc0673f0257 100644 --- a/src/utils/get-cipp-formatting.js +++ b/src/utils/get-cipp-formatting.js @@ -1051,6 +1051,20 @@ export const getCippFormatting = ( ) } + // handle role members + // Without this the CSV/PDF exports fall through to the generic object branch and emit raw + // JSON per member. The on-screen cell keeps rendering as the items button. + if (cellName === 'Members' && Array.isArray(data)) { + return isText ? ( + data + .map((member) => member?.displayName || member?.userPrincipalName || member?.id) + .filter(Boolean) + .join(', ') + ) : ( + + ) + } + // Handle assigned licenses if (cellName === 'assignedLicenses') { var translatedLicenses = getCippLicenseTranslation(data) diff --git a/tests/components/CippComponents/CIPPM365OAuthButton.test.jsx b/tests/components/CippComponents/CIPPM365OAuthButton.test.jsx index 5072ce5c1888..86fa667f867a 100644 --- a/tests/components/CippComponents/CIPPM365OAuthButton.test.jsx +++ b/tests/components/CippComponents/CIPPM365OAuthButton.test.jsx @@ -42,8 +42,16 @@ describe('CIPPM365OAuthButton popup flow', () => { MockBroadcastChannel.instances.length = 0 api.get = getResult({ data: { applicationId: APP_ID } }) openSpy = vi.spyOn(window, 'open') + // The PKCE S256 challenge awaits a real digest, which settles on the event loop + // rather than the microtask queue and so cannot be flushed under fake timers. + // A resolved stub keeps the popup setup that follows it deterministic. + vi.spyOn(globalThis.crypto.subtle, 'digest').mockResolvedValue(new Uint8Array(32).buffer) }) + // Everything after the digest - the BroadcastChannel and the popup watcher - is set up + // in a microtask, so tests touching those have to let the click settle first. + const settleAuthStart = () => act(async () => {}) + afterEach(() => { vi.useRealTimers() vi.unstubAllGlobals() @@ -63,7 +71,7 @@ describe('CIPPM365OAuthButton popup flow', () => { expect(screen.getByRole('button', { name: 'Login with Microsoft' })).toBeEnabled() }) - it('re-enables the button shortly after the sign-in window is closed without a result', () => { + it('re-enables the button shortly after the sign-in window is closed without a result', async () => { const popup = { closed: false, close: vi.fn() } openSpy.mockReturnValue(popup) const onAuthError = vi.fn() @@ -71,6 +79,7 @@ describe('CIPPM365OAuthButton popup flow', () => { fireEvent.click(authButton()) expect(screen.getByRole('button', { name: /Authenticating/ })).toBeDisabled() + await settleAuthStart() popup.closed = true // 1s watcher tick spots the closed window, then the 2s grace period elapses @@ -86,12 +95,13 @@ describe('CIPPM365OAuthButton popup flow', () => { expect(screen.getByRole('button', { name: 'Login with Microsoft' })).toBeEnabled() }) - it('does not report a cancellation when a result arrived before the popup closed', () => { + it('does not report a cancellation when a result arrived before the popup closed', async () => { const popup = { closed: false, close: vi.fn() } openSpy.mockReturnValue(popup) renderWithTheme() fireEvent.click(authButton()) + await settleAuthStart() // the /authredirect callback posts its result, then the popup closes itself act(() => { @@ -114,12 +124,13 @@ describe('CIPPM365OAuthButton popup flow', () => { expect(screen.getByRole('button', { name: 'Login with Microsoft' })).toBeEnabled() }) - it('cleans up the popup watcher when a result arrives', () => { + it('cleans up the popup watcher when a result arrives', async () => { const popup = { closed: false, close: vi.fn() } openSpy.mockReturnValue(popup) renderWithTheme() fireEvent.click(authButton()) + await settleAuthStart() act(() => { lastChannel().onmessage({ data: { type: 'auth_error', error: 'access_denied', errorDescription: 'cancelled' }, @@ -130,4 +141,119 @@ describe('CIPPM365OAuthButton popup flow', () => { // with the watcher cleared, no timers remain to fire popup_closed later expect(vi.getTimerCount()).toBe(0) }) + + it('cancels the pending close check when a result lands during the grace period', async () => { + const popup = { closed: false, close: vi.fn() } + openSpy.mockReturnValue(popup) + renderWithTheme() + + fireEvent.click(authButton()) + await settleAuthStart() + + // the callback closes the popup first, so the watcher schedules its grace check... + popup.closed = true + act(() => { + vi.advanceTimersByTime(1000) + }) + // ...and the result lands inside that window + act(() => { + lastChannel().onmessage({ + data: { type: 'auth_error', error: 'access_denied', errorDescription: 'cancelled' }, + }) + }) + + // nothing left pending that could fire against a subsequent attempt + expect(vi.getTimerCount()).toBe(0) + + act(() => { + vi.advanceTimersByTime(5000) + }) + expect(screen.getByText(/Authentication Error: access_denied/)).toBeInTheDocument() + expect(screen.queryByText(/sign-in window was closed/)).not.toBeInTheDocument() + }) +}) + +describe('CIPPM365OAuthButton device code flow', () => { + let openSpy + + beforeEach(() => { + vi.useFakeTimers() + api.get = getResult({ data: { applicationId: APP_ID } }) + openSpy = vi.spyOn(window, 'open') + // keep the poll pending so the flow stays mid-authentication + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ status: 'pending', error: 'authorization_pending' }), + }) + ) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + const codeResponse = (userCode, deviceCode) => ({ + ok: true, + json: async () => ({ + user_code: userCode, + device_code: deviceCode, + expires_in: 900, + interval: 5, + }), + }) + + const pendingResponse = { + ok: true, + json: async () => ({ status: 'pending', error: 'authorization_pending' }), + } + + it('offers a fresh code instead of locking up when the sign-in window is closed', async () => { + const popup = { closed: false, close: vi.fn() } + openSpy.mockReturnValue(popup) + global.fetch = vi.fn().mockResolvedValue(codeResponse('FHA953X4X', 'dev-code-1')) + + renderWithTheme() + + // first click retrieves the device code + fireEvent.click(screen.getByRole('button', { name: /Login with Microsoft/ })) + await act(async () => {}) + + // second click opens the popup and starts polling + global.fetch = vi.fn().mockResolvedValue(pendingResponse) + fireEvent.click(screen.getByRole('button', { name: /Authenticate with Code/ })) + await act(async () => {}) + expect(screen.getByRole('button', { name: /Authenticating/ })).toBeDisabled() + + // the user closes the sign-in window + popup.closed = true + await act(async () => { + vi.advanceTimersByTime(1000) + }) + + const restart = screen.getByRole('button', { name: /Start over with a new code/ }) + expect(restart).toBeEnabled() + // the copy must not promise that the old code can be reused - it is consumed once entered + expect(screen.getByText(/cannot be used again/)).toBeInTheDocument() + + // starting over requests a new code and retires the old poll + global.fetch = vi.fn().mockResolvedValue(codeResponse('NEWCODE99', 'dev-code-2')) + fireEvent.click(restart) + await act(async () => {}) + + expect(screen.getByText('NEWCODE99')).toBeInTheDocument() + + // the superseded poll must not keep hitting the old device code + global.fetch.mockClear() + await act(async () => { + vi.advanceTimersByTime(30000) + }) + const polledOldCode = global.fetch.mock.calls.some(([url]) => + String(url).includes('dev-code-1') + ) + expect(polledOldCode).toBe(false) + }) }) diff --git a/tests/components/CippComponents/CippAutocomplete.test.jsx b/tests/components/CippComponents/CippAutocomplete.test.jsx index 1aabdf1c5039..e5a47bc5845a 100644 --- a/tests/components/CippComponents/CippAutocomplete.test.jsx +++ b/tests/components/CippComponents/CippAutocomplete.test.jsx @@ -305,4 +305,44 @@ describe('CippAutoComplete', () => { expect(options[0]).toHaveTextContent('Alpha') }) }) + + // Multi-select clears the native input after chips are selected; HTML5 required must + // track selection state or submit falsely fails with "Please fill out this field". + describe('required HTML5 vs selection', () => { + it('marks the input required when empty, and keeps the label required', () => { + renderWithProviders( + {}} + /> + ) + const input = screen.getByRole('combobox') + expect(input).toBeRequired() + expect(document.querySelector('.Mui-required')).toBeTruthy() + expect(document.querySelector('.MuiFormLabel-asterisk')).toBeTruthy() + }) + + it('clears HTML5 required on the input after a multi selection, label stays required', async () => { + const user = userEvent.setup() + renderWithProviders( + {}} + /> + ) + await user.click(screen.getByRole('combobox')) + await user.click(await screen.findByRole('option', { name: 'Alpha' })) + expect(screen.getByRole('combobox')).not.toBeRequired() + expect(document.querySelector('.Mui-required')).toBeTruthy() + expect(document.querySelector('.MuiFormLabel-asterisk')).toBeTruthy() + }) + }) }) diff --git a/tests/components/ReleaseNotesDialog.test.jsx b/tests/components/ReleaseNotesDialog.test.jsx new file mode 100644 index 000000000000..ba53c5cb6c13 --- /dev/null +++ b/tests/components/ReleaseNotesDialog.test.jsx @@ -0,0 +1,109 @@ +import React from 'react' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithProviders } from '../test-utils' + +// public/version.json is rewritten with the image's APP_VERSION at build time, so the running +// build's version is whatever this holds. Mutate between mounts to simulate a different build. +const versionState = vi.hoisted(() => ({ version: '10.8.2' })) +vi.mock('../../public/version.json', () => ({ default: versionState })) + +vi.mock('../../src/api/ApiCall', async () => (await import('../mocks/api-call')).apiCallMock()) + +import { api, getResult } from '../mocks/api-call' +import { ReleaseNotesDialog } from '../../src/components/ReleaseNotesDialog' + +// newest first, the order GitHub returns releases in. v10.9.0 sits ahead of the running build so +// "newest release" and "running release" can never be confused for one another. +const RELEASES = [ + { + name: 'v10.9.0 - Something Newer', + releaseTag: 'v10.9.0', + body: 'Notes for a release this instance has not been updated to yet', + htmlUrl: 'https://github.com/CyberDrain/CIPP/releases/tag/v10.9.0', + publishedAt: '2026-08-20T00:00:00Z', + }, + { + name: 'v10.8.2 - Hotfix', + releaseTag: 'v10.8.2', + body: 'Notes for the hotfix that is actually running', + htmlUrl: 'https://github.com/CyberDrain/CIPP/releases/tag/v10.8.2', + publishedAt: '2026-08-08T00:36:06Z', + }, + { + name: 'v10.8.0 - Ramos Melon Fizz', + releaseTag: 'v10.8.0', + body: 'Notes for the base release of the 10.8 series', + htmlUrl: 'https://github.com/CyberDrain/CIPP/releases/tag/v10.8.0', + publishedAt: '2026-08-07T17:01:49Z', + }, +] + +// stable identity, a fresh literal per mock call loops CippAutoComplete's mapping effect +const catalogResult = getResult({ data: RELEASES }) + +const COOKIE_KEY = 'cipp_release_notice' +const PERMANENT_HIDE_KEY = 'cipp_release_notice_permanently_hidden' + +const flushEffects = () => new Promise((resolve) => setTimeout(resolve, 0)) + +beforeEach(() => { + versionState.version = '10.8.2' + api.get = catalogResult + window.localStorage.clear() + document.cookie = `${COOKIE_KEY}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/` +}) + +describe('ReleaseNotesDialog', () => { + it('opens on the running hotfix release rather than its .0 base release', async () => { + renderWithProviders() + + expect(await screen.findByText('Release notes for v10.8.2 - Hotfix')).toBeInTheDocument() + expect(screen.getByText('Notes for the hotfix that is actually running')).toBeInTheDocument() + }) + + it('stays dismissed on reload after "Don\'t show until next release"', async () => { + const user = userEvent.setup() + + const { unmount } = renderWithProviders() + await screen.findByText('Release notes for v10.8.2 - Hotfix') + await user.click(screen.getByRole('button', { name: "Don't show until next release" })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + + // the tag of the build being run, not the newest tag on GitHub - storing v10.9.0 here left a + // cookie the eligibility check could never match, so the dialog reopened on every page load + expect(document.cookie).toContain(`${COOKIE_KEY}=v10.8.2`) + + unmount() + renderWithProviders() + await flushEffects() + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('opens again once the instance is updated to a newer release', async () => { + document.cookie = `${COOKIE_KEY}=v10.8.2; path=/` + versionState.version = '10.9.0' + + renderWithProviders() + + expect(await screen.findByText('Release notes for v10.9.0 - Something Newer')).toBeInTheDocument() + }) + + it('falls back to the .0 notes when the running version has no release of its own', async () => { + versionState.version = '10.9.1' + + renderWithProviders() + + expect(await screen.findByText('Release notes for v10.9.0 - Something Newer')).toBeInTheDocument() + }) + + it('honours a permanent dismissal', async () => { + window.localStorage.setItem(PERMANENT_HIDE_KEY, 'true') + + renderWithProviders() + await flushEffects() + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) +}) diff --git a/tests/utils/get-cipp-formatting.test.jsx b/tests/utils/get-cipp-formatting.test.jsx index 77e3cfe21b39..ac82c4d0cafb 100644 --- a/tests/utils/get-cipp-formatting.test.jsx +++ b/tests/utils/get-cipp-formatting.test.jsx @@ -124,3 +124,56 @@ describe('getCippFormatting (component mode)', () => { expect(getCippFormatting(null, 'Severity', 'text')).toBe('No data') }) }) + +// Role members exported as raw JSON instead of a name list. +// Shape mirrors Invoke-ListRoles: { displayName, userPrincipalName, id, directoryScopeId }. +describe('getCippFormatting Members (roles export)', () => { + const members = [ + { + displayName: 'Alice Adams', + userPrincipalName: 'alice@contoso.com', + id: '11111111-1111-1111-1111-111111111111', + directoryScopeId: '/', + }, + { + displayName: 'Bob Brown', + userPrincipalName: 'bob@contoso.com', + id: '22222222-2222-2222-2222-222222222222', + directoryScopeId: '/', + }, + ] + + it('joins member display names in text mode', () => { + expect(getCippFormatting(members, 'Members', 'text')).toBe('Alice Adams, Bob Brown') + }) + + it('never emits JSON or [object Object] on the CSV export path', () => { + // csvExportButton calls this with flatten=false first, and only falls back to + // per-member JSON.stringify when the result still contains [object Object]. + const exported = getCippFormatting(members, 'Members', 'text', false, false) + expect(exported).toBe('Alice Adams, Bob Brown') + expect(exported).not.toContain('[object Object]') + expect(exported).not.toContain('displayName') + expect(exported).not.toContain('{') + }) + + it('falls back to UPN then id when a display name is missing', () => { + expect( + getCippFormatting( + [{ userPrincipalName: 'svc@contoso.com' }, { id: 'abc-123' }], + 'Members', + 'text' + ) + ).toBe('svc@contoso.com, abc-123') + }) + + it('renders an empty member list as an empty string', () => { + expect(getCippFormatting([], 'Members', 'text')).toBe('') + }) + + it('still renders the items button in component mode', () => { + const cell = getCippFormatting(members, 'Members') + expect(typeof cell).toBe('object') + expect(cell?.props?.tableTitle).toBe('Members') + }) +}) diff --git a/vitest.setup.js b/vitest.setup.js index c8bfb4160df8..75e16735e490 100644 --- a/vitest.setup.js +++ b/vitest.setup.js @@ -2,6 +2,7 @@ import '@testing-library/jest-dom/vitest' import './tests/mocks/require-context' import { cleanup, configure } from '@testing-library/react' import { afterEach } from 'vitest' +import { webcrypto } from 'node:crypto' // coverage instrumentation slows lazy chunks and fetches past the 1s default configure({ asyncUtilTimeout: 10000 }) @@ -16,6 +17,16 @@ global.ResizeObserver = class ResizeObserver { disconnect() {} } +// jsdom ships crypto.getRandomValues but not crypto.subtle, which the PKCE S256 +// challenge in CIPPM365OAuthButton needs. Node's webcrypto is the same API browsers +// expose in a secure context. +if (globalThis.crypto && !globalThis.crypto.subtle) { + Object.defineProperty(globalThis.crypto, 'subtle', { + value: webcrypto.subtle, + configurable: true, + }) +} + // Suppress jsdom "Not implemented" warnings for getComputedStyle with pseudo-elements const originalConsoleError = console.error console.error = (...args) => {