From bba17ff810a3604da891e118173f01ffafe0d3bc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 10 Aug 2026 20:47:20 +0000 Subject: [PATCH 01/10] Merge pull request #243 from CyberDrain/dev fix: dev to hotfix Synced from CyberDrain/CIPP@037779e2751fef2a36fd1de5a77b406384f9e1f0 --- package.json | 8 +- public/version.json | 4 +- .../CippBaselineStandardItem.jsx | 12 +- .../CippComponents/CIPPDeviceCodeButton.js | 252 --------- .../CippComponents/CIPPM365OAuthButton.jsx | 273 +++++++--- .../CippComponents/CippAutocomplete.jsx | 16 +- src/components/CippPdf/ReportDocument.jsx | 9 +- .../CippPdf/reportPdfPrimitives.jsx | 5 +- src/components/CippPdf/reportTheme.js | 11 +- src/components/CippPdf/useBrandingSettings.js | 31 +- src/components/CippPdf/useReportVariables.js | 23 +- .../CippSettings/CippBrandingSettings.jsx | 9 +- src/components/CippWizard/CippSAMDeploy.jsx | 9 + .../CippWizard/CippTenantModeDeploy.jsx | 44 ++ src/components/ReleaseNotesDialog.js | 29 +- src/contexts/settings-context.js | 7 +- src/pages/cipp/settings/partner-webhooks.js | 16 +- .../reports/inactive-users-report/index.js | 4 +- .../tenant/administration/tenants/index.js | 1 + src/pages/tenant/baselines/alignment/index.js | 507 +++++++++++++++++- src/pages/tenant/baselines/templates/index.js | 43 ++ src/pages/tools/community-repos/index.js | 1 + src/utils/get-cipp-formatting.js | 14 + .../CIPPM365OAuthButton.test.jsx | 132 ++++- .../CippComponents/CippAutocomplete.test.jsx | 40 ++ tests/components/ReleaseNotesDialog.test.jsx | 109 ++++ tests/utils/get-cipp-formatting.test.jsx | 53 ++ vitest.setup.js | 11 + 28 files changed, 1237 insertions(+), 436 deletions(-) delete mode 100644 src/components/CippComponents/CIPPDeviceCodeButton.js create mode 100644 tests/components/ReleaseNotesDialog.test.jsx diff --git a/package.json b/package.json index 748f3dee1700..f5e3782ce87d 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": { @@ -8,7 +8,7 @@ }, "license": "AGPL-3.0", "engines": { - "node": "^22.22.0" + "node": "^22.22.2" }, "repository": { "type": "git", @@ -135,7 +135,7 @@ "eslint": "^9.39.4", "eslint-config-next": "^16.2.10", "eslint-config-prettier": "^10.1.8", - "jsdom": "29.0.1", + "jsdom": "30.0.1", "msw": "2.12.14", "msw-storybook-addon": "3.0.0", "playwright": "1.59.1", @@ -153,4 +153,4 @@ "resolutions": { "vite": "7.3.6" } -} +} \ No newline at end of file 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) => { From 3966d8853133700b3c02ee8c568f2acb8110e3c1 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 10 Aug 2026 16:48:00 -0400 Subject: [PATCH 02/10] Update Node.js and jsdom version requirements Updated Node.js and jsdom versions in package.json. Signed-off-by: John Duprey --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index f5e3782ce87d..5476801e962e 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ }, "license": "AGPL-3.0", "engines": { - "node": "^22.22.2" + "node": "^22.22.0" }, "repository": { "type": "git", @@ -135,7 +135,7 @@ "eslint": "^9.39.4", "eslint-config-next": "^16.2.10", "eslint-config-prettier": "^10.1.8", - "jsdom": "30.0.1", + "jsdom": "29.0.1", "msw": "2.12.14", "msw-storybook-addon": "3.0.0", "playwright": "1.59.1", @@ -153,4 +153,4 @@ "resolutions": { "vite": "7.3.6" } -} \ No newline at end of file +} From 209144969db3a214b812cfa0f2f1414dbf26c018 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 12 Aug 2026 20:00:17 +0000 Subject: [PATCH 03/10] Merge pull request #273 from CyberDrain/dev fix: Dev to hotfix Synced from CyberDrain/CIPP@c64f8818cd535608efb10ca295f295888db26869 --- package.json | 8 +- public/version.json | 2 +- src/components/BECRemediationReportButton.js | 511 ++++++++++++++++-- .../CippAllTenants/useAllTenantsDashboard.js | 128 ++--- .../CippComponents/CippCAPolicyBuilder.jsx | 343 +++++++++--- .../CippComponents/CippMultiQueueTracker.jsx | 5 +- .../CippPdf/PermissionsReportButton.jsx | 8 +- src/components/CippPdf/ReportDocument.jsx | 13 +- .../CippPdf/SharingReportButton.jsx | 8 +- src/components/CippPdf/index.js | 4 + src/components/CippPdf/previewSampleData.js | 206 ++++++- .../CippPdf/reportPdfPrimitives.jsx | 16 +- src/components/CippPdf/reportTheme.js | 23 + .../CippSettings/CippBrandingCoverPreview.jsx | 26 +- .../CippSettings/CippBrandingSettings.jsx | 26 +- src/components/CippTable/CippQueueTracker.js | 7 +- src/data/M365Licenses.json | 64 +++ src/data/standards.json | 8 +- src/pages/copilot/settings/index.js | 35 +- .../administration/users/user/bec.jsx | 457 ++++++++++++++-- .../administration/users/user/exchange.jsx | 66 +-- 21 files changed, 1588 insertions(+), 376 deletions(-) diff --git a/package.json b/package.json index 5476801e962e..df7833c3db62 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cipp", - "version": "10.8.3", + "version": "10.8.4", "author": "CIPP Contributors", "homepage": "https://cipp.app/", "bugs": { @@ -8,7 +8,7 @@ }, "license": "AGPL-3.0", "engines": { - "node": "^22.22.0" + "node": "^22.22.2" }, "repository": { "type": "git", @@ -135,7 +135,7 @@ "eslint": "^9.39.4", "eslint-config-next": "^16.2.10", "eslint-config-prettier": "^10.1.8", - "jsdom": "29.0.1", + "jsdom": "30.0.1", "msw": "2.12.14", "msw-storybook-addon": "3.0.0", "playwright": "1.59.1", @@ -153,4 +153,4 @@ "resolutions": { "vite": "7.3.6" } -} +} \ No newline at end of file diff --git a/public/version.json b/public/version.json index 682cb83714dc..bfb273510e9e 100644 --- a/public/version.json +++ b/public/version.json @@ -1,3 +1,3 @@ { - "version": "10.8.3" + "version": "10.8.4" } \ No newline at end of file diff --git a/src/components/BECRemediationReportButton.js b/src/components/BECRemediationReportButton.js index bd0255fdb1fc..dd67d546cb18 100644 --- a/src/components/BECRemediationReportButton.js +++ b/src/components/BECRemediationReportButton.js @@ -77,18 +77,46 @@ export const BECRemediationReportDocument = ({ 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 intuneWindowStart = (() => { + 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(Date.now() - 7 * 24 * 60 * 60 * 1000) + return new Date(new Date().getTime() - 7 * 24 * 60 * 60 * 1000) } return new Date(extractedAt.getTime() - 7 * 24 * 60 * 60 * 1000) })() @@ -97,10 +125,23 @@ export const BECRemediationReportDocument = ({ if (!device?.enrolledDateTime) return false const enrolled = new Date(device.enrolledDateTime) if (Number.isNaN(enrolled.getTime())) return false - return enrolled >= intuneWindowStart + 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 @@ -112,8 +153,12 @@ export const BECRemediationReportDocument = ({ let threatScore = 0 if (stats.newRules > 0) threatScore += 3 if (stats.ruleChanges > 0) threatScore += 3 - if (stats.permissionChanges > 0) threatScore += 2 - if (stats.newApps > 0) threatScore += 2 + // 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 @@ -121,6 +166,20 @@ export const BECRemediationReportDocument = ({ 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' } @@ -164,7 +223,7 @@ export const BECRemediationReportDocument = ({ {userData?.userPrincipalName} within{' '} {tenantName}. The investigation analyzed suspicious activity indicators including mailbox rules, permission changes, new - applications, and authentication patterns over a 7-day period. + applications, authentication patterns, and sign-in locations over a 7-day period. @@ -182,8 +241,8 @@ export const BECRemediationReportDocument = ({ stats={[ { value: stats.newRules, label: 'Mailbox Rules' }, { value: stats.permissionChanges, label: 'Permission Changes' }, - { value: stats.newApps, label: 'New Applications' }, - { value: stats.newUsers, label: 'New Users' }, + { value: stats.foreignSignIns, label: 'Foreign Sign-ins' }, + { value: stats.maliciousApps, label: 'Malicious Apps' }, ]} /> @@ -202,6 +261,10 @@ export const BECRemediationReportDocument = ({ 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'} + @@ -279,7 +342,7 @@ export const BECRemediationReportDocument = ({ {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. @@ -304,7 +367,7 @@ export const BECRemediationReportDocument = ({ )} {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. @@ -315,6 +378,10 @@ export const BECRemediationReportDocument = ({ 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}`} ))} @@ -327,7 +394,7 @@ export const BECRemediationReportDocument = ({ )} {stats.newRules === 0 && stats.ruleChanges === 0 && ( - + No mailbox rules were detected that match suspicious patterns. This is a positive indicator. @@ -347,7 +414,7 @@ export const BECRemediationReportDocument = ({ {stats.newUsers > 0 ? ( <> - + The following users were created in the last 7 days. Verify that each account creation was authorized and legitimate. @@ -366,7 +433,7 @@ export const BECRemediationReportDocument = ({ )} ) : ( - + No new user accounts were created during the analysis period. )} @@ -380,9 +447,17 @@ export const BECRemediationReportDocument = ({ 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. @@ -394,6 +469,12 @@ export const BECRemediationReportDocument = ({ 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 && ( @@ -403,15 +484,41 @@ export const BECRemediationReportDocument = ({ )} ) : ( - - No new applications were authorized during the analysis period. - + (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: PERMISSIONS, MFA, PASSWORDS */} - + {/* CHECK 4, 5, 6, 7: PERMISSIONS, SENT MAIL, MFA, PASSWORDS */} + {/* Check 4: Mailbox Permission Changes */}
    @@ -423,7 +530,7 @@ export const BECRemediationReportDocument = ({ {stats.permissionChanges > 0 ? ( <> - + Mailbox permission changes were detected. Verify that each change was authorized and necessary for legitimate business purposes. @@ -435,6 +542,8 @@ export const BECRemediationReportDocument = ({ Target: {change.ObjectId || 'N/A'} {'\n'} Permissions: {change.Permissions || 'Unknown'} + {change.TargetsSuspect === true && + '\n⚠️ Targets the investigated mailbox'} ))} {becData.MailboxPermissionChanges.length > 5 && ( @@ -444,14 +553,107 @@ export const BECRemediationReportDocument = ({ )} ) : ( - + No mailbox permission changes were detected during the analysis period. )}
    - {/* Check 5: MFA Devices */} -
    + {/* 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 @@ -461,28 +663,42 @@ export const BECRemediationReportDocument = ({ {stats.mfaDevices > 0 ? ( <> - ℹ {stats.mfaDevices} MFA device(s) registered. Verify each device belongs to the - user. + ℹ️ {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.slice(0, 5).map((device, index) => ( - - Display Name: {device.displayName || 'N/A'} - {'\n'} - Registered: {formatDate(device.createdDateTime)} - - ))} + {[...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 6: Password Changes */} -
    + {/* 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 @@ -492,7 +708,7 @@ export const BECRemediationReportDocument = ({ {stats.passwordChanges > 0 ? ( <> - ℹ {stats.passwordChanges} password change(s) detected in the tenant during the + ℹ️ {stats.passwordChanges} password change(s) detected in the tenant during the analysis period. @@ -503,16 +719,26 @@ export const BECRemediationReportDocument = ({ 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. + ℹ️ No password changes detected during the analysis period. )}
    + + + {/* CHECK 8, 9, 10: SENDER LISTS, DEVICES, LOCATIONS */} + - {/* Check 7: Trusted & Blocked Senders */} -
    + {/* 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 @@ -520,9 +746,17 @@ export const BECRemediationReportDocument = ({ 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. @@ -530,32 +764,57 @@ export const BECRemediationReportDocument = ({ {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.trustedSenders === 0 && stats.blockedSenders === 0 && stats.safelistChanges === 0 && ( - - No trusted or blocked sender/domain entries were found on this mailbox. - + {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 8: Intune Devices */} -
    + {/* 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 @@ -563,7 +822,7 @@ export const BECRemediationReportDocument = ({ {becData?.IntuneDevicesError ? ( - + {becData.IntuneDevicesError} {'\n'} An empty device list here does not mean the user has no Intune devices. @@ -571,7 +830,7 @@ export const BECRemediationReportDocument = ({ ) : stats.intuneDevices > 0 ? ( <> - ℹ {stats.intuneDevices} Intune-managed device(s) associated with this user + ℹ️ {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.'} @@ -590,13 +849,142 @@ export const BECRemediationReportDocument = ({ {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 */} @@ -736,6 +1124,8 @@ export const BECRemediationReportDocument = ({ {'\n'} Analysis Period: 7 days {'\n'} + Assigned Usage Location: {locationAnalysis?.UsageLocation || 'Not assigned'} + {'\n'} Audit Log Status: {becData?.ExtractResult || 'Unknown'} @@ -746,14 +1136,25 @@ export const BECRemediationReportDocument = ({ {'\n'} Rule Changes: {stats.ruleChanges} {'\n'} - Permission Changes: {stats.permissionChanges} + 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} @@ -762,9 +1163,17 @@ export const BECRemediationReportDocument = ({ {'\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}
    diff --git a/src/components/CippAllTenants/useAllTenantsDashboard.js b/src/components/CippAllTenants/useAllTenantsDashboard.js index 5d3882102119..09426e63bb0f 100644 --- a/src/components/CippAllTenants/useAllTenantsDashboard.js +++ b/src/components/CippAllTenants/useAllTenantsDashboard.js @@ -118,17 +118,20 @@ export const useAllTenantsDashboard = () => { waiting: true, }) + // summary returns the estate roll-up only. The row list is one entry per tenant per standard, and + // this card renders four bucket counts, an average, four low scorers and two pending totals. const alignmentApi = ApiGetCall({ url: '/api/ListTenantAlignment', + data: { summary: true }, queryKey: 'AllTenantsDashboard-Alignment', waiting: true, }) - // summaryOnly projects away ResultMarkdown/ResultDataJson server-side — this card only counts - // rows, and those two columns are unbounded blobs that otherwise dominate the payload. + // countsOnly returns the aggregates with no rows. Deriving them here pulled the estate's whole + // failed-test set over the wire, growing linearly with tenant count. const failedTestsApi = ApiGetCall({ url: '/api/ListTestResultsTenants', - data: { status: 'Failed', summaryOnly: 'true' }, + data: { status: 'Failed', countsOnly: 'true' }, queryKey: 'AllTenantsDashboard-FailedTests', waiting: true, }) @@ -226,104 +229,45 @@ export const useAllTenantsDashboard = () => { /* ---------------------------------------------------------------- alignment */ const alignment = useMemo(() => { - const rows = asArray(alignmentApi.data) - const byTenant = new Map() - let pendingDeviations = 0 - const pendingByTenant = new Map() - - // ListTenantAlignment serialises camelCase on the wire even though the PowerShell object that - // builds it is PascalCase. Accept both so this keeps working if that ever normalises. - rows.forEach((row) => { - const key = row?.tenantFilter ?? row?.TenantFilter - if (!key) return - const score = Number( - row?.combinedAlignmentScore ?? - row?.CombinedScore ?? - row?.alignmentScore ?? - row?.AlignmentScore ?? - 0 - ) - const existing = byTenant.get(key) ?? { total: 0, count: 0 } - byTenant.set(key, { - total: existing.total + score, - count: existing.count + 1, - }) - - const pending = Number(row?.pendingDeviationsCount ?? row?.PendingDeviationsCount ?? 0) - if (pending > 0) { - pendingDeviations += pending - pendingByTenant.set(key, (pendingByTenant.get(key) ?? 0) + pending) - } - }) - - const scores = [] - byTenant.forEach((value, key) => { - scores.push({ - tenant: key, - name: displayNameByDomain.get(key) ?? key, - score: value.count ? Math.round(value.total / value.count) : 0, - }) - }) - - const buckets = { strong: 0, good: 0, weak: 0, poor: 0 } - scores.forEach(({ score }) => { - if (score >= 90) buckets.strong += 1 - else if (score >= 75) buckets.good += 1 - else if (score >= 50) buckets.weak += 1 - else buckets.poor += 1 - }) - - const average = scores.length - ? Math.round(scores.reduce((sum, item) => sum + item.score, 0) / scores.length) - : 0 + const summary = alignmentApi.data ?? {} + const buckets = summary.Buckets ?? {} return { - scores, - buckets, - average, - lowest: [...scores].sort((a, b) => a.score - b.score).slice(0, 4), - pendingDeviations, - pendingTenantCount: pendingByTenant.size, + // Only ever read for its length — the endpoint returns the count directly. + scores: { length: summary.ScoredTenantCount ?? 0 }, + buckets: { + strong: buckets.Strong ?? 0, + good: buckets.Good ?? 0, + weak: buckets.Weak ?? 0, + poor: buckets.Poor ?? 0, + }, + average: summary.Average ?? 0, + lowest: (summary.Lowest ?? []).map((item) => ({ + tenant: item.Tenant, + name: item.Name ?? item.Tenant, + score: item.Score ?? 0, + })), + pendingDeviations: summary.PendingDeviations ?? 0, + pendingTenantCount: summary.PendingTenantCount ?? 0, } - }, [alignmentApi.data, displayNameByDomain]) + }, [alignmentApi.data]) /* ------------------------------------------------------------- test results */ const tests = useMemo(() => { - const rows = asArray(failedTestsApi.data) - const identityChecks = new Map() - const highRiskTenants = new Set() - let high = 0 - - rows.forEach((row) => { - if (String(row?.Risk ?? '').toLowerCase() === 'high') { - high += 1 - if (row?.Tenant) highRiskTenants.add(row.Tenant) - } - - if (String(row?.TestType ?? '').toLowerCase() === 'identity' && row?.Name) { - const tenantSet = identityChecks.get(row.Name) ?? new Set() - if (row?.Tenant) tenantSet.add(row.Tenant) - identityChecks.set(row.Name, tenantSet) - } - }) - - const identityRows = [...identityChecks.entries()] - .map(([label, tenantSet]) => ({ label, tenantCount: tenantSet.size })) - .sort((a, b) => b.tenantCount - a.tenantCount) - .slice(0, 4) - - const identityTenantCount = new Set( - rows - .filter((row) => String(row?.TestType ?? '').toLowerCase() === 'identity' && row?.Tenant) - .map((row) => row.Tenant) - ).size + const counts = failedTestsApi.data?.Counts ?? {} + const byTestType = counts.ByTestType ?? {} + // The facet is keyed by the TestType as stored ('Identity'); match without assuming casing. + const identityKey = Object.keys(byTestType).find((key) => key.toLowerCase() === 'identity') + const identity = (identityKey ? byTestType[identityKey] : null) ?? {} return { - identityRows, - identityTenantCount, - high, - highRiskTenantCount: highRiskTenants.size, + identityRows: (identity.TopChecks ?? []) + .slice(0, 4) + .map((check) => ({ label: check.Name, tenantCount: check.TenantCount })), + identityTenantCount: identity.Tenants ?? 0, + high: counts.HighRiskFailed ?? 0, + highRiskTenantCount: counts.HighRiskTenants ?? 0, } }, [failedTestsApi.data]) diff --git a/src/components/CippComponents/CippCAPolicyBuilder.jsx b/src/components/CippComponents/CippCAPolicyBuilder.jsx index 39e4bc968144..ddbc9b4de2a6 100644 --- a/src/components/CippComponents/CippCAPolicyBuilder.jsx +++ b/src/components/CippComponents/CippCAPolicyBuilder.jsx @@ -108,6 +108,114 @@ function SectionHeader({ title, description, requiresLicense, icon }) { ); } +/** + * The guest / external user block, which Graph models identically on the include and the + * exclude side. Rendered twice from UsersSection rather than duplicated. + */ +function GuestsOrExternalUsersFields({ formControl, disabled, prefix, direction, typeOptions }) { + const base = `${prefix}.${direction}GuestsOrExternalUsers`; + const Verb = direction === "include" ? "Include" : "Exclude"; + const scopeHelp = + direction === "include" + ? "Choose whether the policy applies to all external tenants or specific ones. Only relevant for external user types (not internal guests)." + : "Choose whether the exclusion applies to all external tenants or specific ones. Only relevant for external user types (not internal guests)."; + + // Entra rejects an include-guests assignment (error 1119) when Include Users also carries one of + // its special values. The exclude side has no such constraint, so only watch on the include side. + const includeUsers = useWatch({ control: formControl.control, name: `${prefix}.includeUsers` }); + const guestTypes = useWatch({ control: formControl.control, name: `${base}.guestOrExternalUserTypes` }); + const conflictsWithIncludeUsers = useMemo(() => { + if (direction !== "include") return false; + const hasGuestTypes = Array.isArray(guestTypes) ? guestTypes.length > 0 : Boolean(guestTypes); + if (!hasGuestTypes) return false; + const users = Array.isArray(includeUsers) ? includeUsers : [includeUsers]; + return users.some((u) => ["All", "None", "GuestsOrExternalUsers"].includes(u?.value ?? u)); + }, [direction, guestTypes, includeUsers]); + + return ( + <> + + + + {Verb} Guests or External Users + + + + + + + Select one or more external user types to {direction} {direction === "include" ? "in" : "from"} this + policy. + + {conflictsWithIncludeUsers && ( + + Entra ID rejects this combination. Clear "Include Users" — an include-guests + assignment cannot be combined with All, None or GuestsOrExternalUsers. + + )} + + + + + + {scopeHelp} + + + + + + + Enter the tenant IDs to scope this to (e.g. your partner tenant ID for a service + provider {direction === "include" ? "inclusion" : "exclusion"}). + + + + + + ); +} + // --------------------------------------------------------------------------- // Users & Groups section // --------------------------------------------------------------------------- @@ -215,79 +323,21 @@ function UsersSection({ formControl, disabled, prefix = "conditions.users" }) { /> - {/* Guest / External User Exclusions */} - - - - Exclude Guests or External Users - - - - - - - Select one or more external user types to exclude from this policy. - - - - - - - Choose whether the exclusion applies to all external tenants or specific ones. Only - relevant for external user types (not internal guests). - - - - - - - Enter the tenant IDs to scope this exclusion to (e.g. your partner tenant ID for - service provider exclusion). - - - - + disabled={disabled} + prefix={prefix} + direction="include" + typeOptions={guestTypeOpts} + /> + ); } @@ -305,6 +355,11 @@ function ApplicationsSection({ formControl, disabled, prefix = "conditions.appli () => enumToOptions(schemaDef?.properties?.includeUserActions), [schemaDef] ); + const filterSchema = resolveRef("#/$defs/conditionalAccessFilter"); + const filterModeOpts = useMemo( + () => enumToOptions(filterSchema?.properties?.mode), + [filterSchema] + ); return ( @@ -344,6 +399,52 @@ function ApplicationsSection({ formControl, disabled, prefix = "conditions.appli options={userActionOpts} /> + + + + Used instead of cloud apps. In a template, deployment matches these by display name and + creates the authentication context in the tenant if it is missing. + + + + {/* Application filter */} + + + + Application Filter + + + + + + + + + ); } @@ -399,6 +500,17 @@ function ConditionsSection({ formControl, disabled }) { [locationSchema] ); + const clientAppsSchema = resolveRef("#/$defs/conditionalAccessClientApplications"); + const includeSpOpts = useMemo( + () => specialValueOptions(clientAppsSchema?.properties?.includeServicePrincipals), + [clientAppsSchema] + ); + const filterSchema = resolveRef("#/$defs/conditionalAccessFilter"); + const filterModeOpts = useMemo( + () => enumToOptions(filterSchema?.properties?.mode), + [filterSchema] + ); + return ( {/* Client app types */} @@ -570,6 +682,67 @@ function ConditionsSection({ formControl, disabled }) { options={authFlowOpts} /> + + {/* Workload identities */} + + + + + Workload Identities + + + + + + + + + Scopes the policy to workload identities instead of users. Leave empty for a user policy. + + + + + + + + + + + ); } @@ -625,7 +798,10 @@ function GrantControlsSection({ formControl, disabled }) { ? gc.builtInControls.length : gc.builtInControls) || gc.authenticationStrength?.id || - (Array.isArray(gc.termsOfUse) ? gc.termsOfUse.length : gc.termsOfUse); + (Array.isArray(gc.termsOfUse) ? gc.termsOfUse.length : gc.termsOfUse) || + (Array.isArray(gc.customAuthenticationFactors) + ? gc.customAuthenticationFactors.length + : gc.customAuthenticationFactors); if (hasControls && !(value?.value ?? value)) { return "Grant operator is required when grant controls are set"; } @@ -676,6 +852,21 @@ function GrantControlsSection({ formControl, disabled }) { placeholder="Terms of use agreement IDs" /> + + + + Legacy custom controls from an external identity provider, referenced by ID. + + ); } @@ -1356,6 +1547,9 @@ export default CippCAPolicyBuilder; * Call this in your form's submit handler to strip out { label, value } * wrapper objects from autoComplete fields, remove empty/null branches, * and ensure the JSON is ready to send to AddCAPolicy / AddCATemplate. + * + * Absent keys are fine: the backend canonicalizer (Format-CIPPCAPolicy) restores every managed + * key it needs as its cleared form at deploy/edit time, so this stays a plain payload cleanup. */ export function extractCAPolicyJSON(formValues) { const clean = (obj) => { @@ -1449,7 +1643,8 @@ export function extractCAPolicyJSON(formValues) { } // Post-process: strip session control sub-objects where isEnabled is false. - // Graph validates fields like `mode` even when disabled — safest to omit entirely. + // Graph validates fields like `mode` even when disabled — safest to omit entirely; the backend + // canonicalizer turns the resulting absence into the null that clears it on the policy. if (cleaned.sessionControls) { const sessionKeys = [ "applicationEnforcedRestrictions", diff --git a/src/components/CippComponents/CippMultiQueueTracker.jsx b/src/components/CippComponents/CippMultiQueueTracker.jsx index 4be060760ed3..d1c29443905b 100644 --- a/src/components/CippComponents/CippMultiQueueTracker.jsx +++ b/src/components/CippComponents/CippMultiQueueTracker.jsx @@ -48,7 +48,10 @@ export const CippMultiQueueTracker = ({ queueIds = [], relatedQueryKeys = [], la data: { QueueIds: idKey }, queryKey: `CippQueues-${idKey || 'none'}`, waiting: ids.length > 0, - refetchInterval: (data) => (isFinished(data?.Summary?.Status) ? false : 3000), + // TanStack Query v5 hands this callback the Query object, not the data. Reading the data + // off query.state is what makes the interval actually return false on completion - with + // the v4 (data) signature the status is never found and the poll runs forever. + refetchInterval: (query) => (isFinished(query?.state?.data?.Summary?.Status) ? false : 3000), refetchOnWindowFocus: false, staleTime: 0, }) diff --git a/src/components/CippPdf/PermissionsReportButton.jsx b/src/components/CippPdf/PermissionsReportButton.jsx index 6be690765885..e5babd008db3 100644 --- a/src/components/CippPdf/PermissionsReportButton.jsx +++ b/src/components/CippPdf/PermissionsReportButton.jsx @@ -226,7 +226,7 @@ export const PermissionsReportDocument = ({ /> ) : ( - + No site or library grants access to Everyone, Everyone except external users, or All Users. @@ -261,7 +261,7 @@ export const PermissionsReportDocument = ({ /> ) : ( - + No guest or external identity holds a permission on a scanned site or library. )} @@ -301,7 +301,7 @@ export const PermissionsReportDocument = ({ /> ) : ( - + No user or directory group holds Full Control outside a site's Owners group. )} @@ -321,7 +321,7 @@ export const PermissionsReportDocument = ({ 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. diff --git a/src/components/CippPdf/ReportDocument.jsx b/src/components/CippPdf/ReportDocument.jsx index 5ea627587a0b..f5810cd1eaeb 100644 --- a/src/components/CippPdf/ReportDocument.jsx +++ b/src/components/CippPdf/ReportDocument.jsx @@ -1,6 +1,6 @@ import { Document } from '@react-pdf/renderer' import { ReportProvider } from './reportContext' -import { createReportTheme } from './reportTheme' +import { applyFooterText, createReportTheme } from './reportTheme' import { createReportStyles, DEFAULT_PAGE_SETUP } from './reportPdfStyles' import { CoverPage } from './reportPdfPrimitives' import { resolveCoverImage } from './resolveCoverImage' @@ -83,6 +83,14 @@ export const ReportDocument = ({ const context = { theme, styles, variables, logo, footerLabel, size, orientation, date } + // Branding's cover note wins; a report's own wording is the fallback. Leave the prop undefined + // when neither is set so CoverPage's default confidentiality line still appears. Variables are + // filled here so a configured `%tenantname%` note resolves the same way the page footer does. + const coverNoteTemplate = theme.coverFooterText || coverFooterNote + const coverNote = coverNoteTemplate + ? applyFooterText(coverNoteTemplate, variables) + : undefined + return ( @@ -103,8 +111,7 @@ export const ReportDocument = ({ // Naming the client on the cover is what makes it a client report. Every report wanted // it and each one printed it slightly differently; `coverTenant={false}` opts out. tenantName={coverTenant === false ? null : coverTenant || tenantName} - // Branding's cover note wins; a report's own wording is the fallback. - footerNote={theme.coverFooterText || coverFooterNote} + footerNote={coverNote} > {coverMeta} diff --git a/src/components/CippPdf/SharingReportButton.jsx b/src/components/CippPdf/SharingReportButton.jsx index 3ff51601b3cf..136a515f0c93 100644 --- a/src/components/CippPdf/SharingReportButton.jsx +++ b/src/components/CippPdf/SharingReportButton.jsx @@ -211,7 +211,7 @@ export const SharingReportDocument = ({ /> ) : ( - + No anonymous link grants write access. )} @@ -246,7 +246,7 @@ export const SharingReportDocument = ({ /> ) : ( - + Every anonymous link has an expiry date set. )} @@ -284,7 +284,7 @@ export const SharingReportDocument = ({ /> ) : ( - + External and anonymous shares point at individual files rather than folders. )} @@ -316,7 +316,7 @@ export const SharingReportDocument = ({ /> ) : ( - + Nothing has been shared with an identity outside the organisation. )} diff --git a/src/components/CippPdf/index.js b/src/components/CippPdf/index.js index 71468d969335..fbd7dba3192f 100644 --- a/src/components/CippPdf/index.js +++ b/src/components/CippPdf/index.js @@ -14,6 +14,10 @@ export { REPORT_COLOURS, REPORT_SERIES_SEMANTIC, applyReportVariables, + applyFooterText, + applyWatermarkText, + FOOTER_MAX_LENGTH, + WATERMARK_MAX_LENGTH, REPORT_COLOUR_ROLES, asReportTheme, buildPalette, diff --git a/src/components/CippPdf/previewSampleData.js b/src/components/CippPdf/previewSampleData.js index 146556bba968..f3a607f5088d 100644 --- a/src/components/CippPdf/previewSampleData.js +++ b/src/components/CippPdf/previewSampleData.js @@ -350,22 +350,204 @@ export const SAMPLE_SHADOW_AI = { ], } -/** BEC remediation report. */ +/** 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' }, becData: { ExtractedAt: '2026-08-05T09:00:00Z', - ExtractResult: 'Completed', - NewRules: [{ Name: 'Sample forwarding rule', MoveToFolder: 'RSS Feeds' }], - InboxRuleChanges: [{ Name: 'Sample rule change' }], - NewUsers: [], - AddedApps: [{ DisplayName: 'Sample OAuth app' }], - MailboxPermissionChanges: [{ Grantee: 'sample.other@example.com' }], - MFADevices: [{ Device: 'Sample phone' }], - ChangedPasswords: [{ User: 'sample.user@example.com' }], - TrustedSenders: [], - BlockedSenders: [], - SafelistChanges: [], + ExtractResult: 'Successfully extracted logs from auditlog', + AnalysisWindowDays: 7, + NewRules: [ + { + Name: 'Sample forwarding rule', + Description: 'Move messages from billing@example.com to folder RSS Feeds', + MoveToFolder: 'RSS Feeds', + RecentlyChanged: true, + }, + ], + InboxRuleChanges: [ + { + Operation: 'New-InboxRule', + UserKey: 'sample.user@example.com', + RuleName: 'Sample forwarding rule', + Parameters: 'MoveToFolder=RSS Feeds; MarkAsRead=True', + Date: '2026-08-03T11:24:00Z', + ClientIP: '203.0.113.10', + Country: 'NG', + City: 'Lagos', + ForeignLocation: true, + }, + ], + NewUsers: [ + { + displayName: 'Sample Contractor', + userPrincipalName: 'sample.contractor@example.com', + createdDateTime: '2026-08-02T08:00:00Z', + }, + ], + AddedApps: [ + { + displayName: 'Sample OAuth app', + appId: '00000000-0000-0000-0000-000000000001', + publisher: 'Sample Publisher', + createdDateTime: '2026-08-01T10:00:00Z', + MaliciousMatch: null, + }, + ], + MaliciousSPs: [ + { + displayName: 'Sample Mail Sync Tool', + appId: '00000000-0000-0000-0000-000000000002', + accountEnabled: true, + createdDateTime: '2026-07-30T09:30:00Z', + CatalogName: 'Sample Mail Sync Tool', + Categories: ['Mailbox exfiltration', 'Business Email Compromise'], + Description: 'Sample catalog entry used for preview data.', + }, + ], + MailboxPermissionChanges: [ + { + Operation: 'Add-MailboxPermission', + UserKey: 'admin@example.com', + ObjectId: 'sample.user@example.com', + Permissions: 'FullAccess', + TargetsSuspect: true, + }, + ], + SentMessages: [ + { + MessageTraceId: '00000000-0000-0000-0000-000000000003', + Status: 'Delivered', + Subject: 'Sample invoice', + RecipientAddress: 'supplier@example.net', + Received: '2026-08-04 15:02:11Z', + FromIP: '203.0.113.10', + Country: 'NG', + City: 'Lagos', + ForeignLocation: true, + }, + ], + SentMessageAnalysis: { + TotalMessages: 47, + TotalRecipients: 212, + RepeatedSubjects: [ + { + Subject: 'Sample invoice', + MessageCount: 38, + RecipientCount: 190, + FirstSent: '2026-08-04 14:55:00Z', + LastSent: '2026-08-04 15:20:00Z', + Flagged: true, + }, + ], + FlaggedSubjectCount: 1, + Bursts: [ + { + WindowStart: '2026-08-04 15:00:00Z', + WindowMinutes: 10, + MessageCount: 31, + RecipientCount: 160, + TopSubject: 'Sample invoice', + }, + ], + Flagged: true, + }, + MFADevices: [ + { + '@odata.type': '#microsoft.graph.microsoftAuthenticatorAuthenticationMethod', + displayName: 'Sample phone', + createdDateTime: '2026-08-03T12:00:00Z', + }, + ], + ChangedPasswords: [ + { + displayName: 'Sample User', + userPrincipalName: 'sample.user@example.com', + lastPasswordChangeDateTime: '2026-08-03T12:05:00Z', + }, + ], + TrustedSenders: ['trusted@example.net', 'example-partner.com'], + BlockedSenders: ['security-alerts@example.org'], + SafelistChanges: [ + { + Operation: 'Set-MailboxJunkEmailConfiguration', + UserKey: 'sample.user@example.com', + Date: '2026-08-03T11:30:00Z', + ClientIP: '203.0.113.10', + Country: 'NG', + City: 'Lagos', + ForeignLocation: true, + Trusted: ['attacker-domain.example'], + Blocked: null, + }, + ], + SharingChanges: [ + { + Operation: 'AnonymousLinkCreated', + UserKey: 'sample.user@example.com', + 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', + Target: null, + TargetType: null, + ClientIP: '203.0.113.10', + Country: 'NG', + City: 'Lagos', + ForeignLocation: true, + }, + ], + IntuneDevices: [ + { + id: '00000000-0000-0000-0000-000000000004', + deviceName: 'SAMPLE-VM01', + operatingSystem: 'Windows', + osVersion: '10.0.26100', + complianceState: 'noncompliant', + enrolledDateTime: '2026-08-03T13:00:00Z', + lastSyncDateTime: '2026-08-05T08:00:00Z', + deviceEnrollmentType: 'windowsAzureADJoin', + serialNumber: 'SAMPLE1234', + }, + ], + SuspectUserSignIns: [ + { + CreatedDateTime: '2026-08-04T22:14:00Z', + AppDisplayName: 'Office 365 Exchange Online', + ClientAppUsed: 'Browser', + Status: 'Success', + IPAddress: '203.0.113.10', + Country: 'NG', + City: 'Lagos', + ForeignLocation: true, + }, + { + CreatedDateTime: '2026-08-04T09:02:00Z', + AppDisplayName: 'Microsoft Teams', + ClientAppUsed: 'Mobile Apps and Desktop clients', + Status: 'Success', + IPAddress: '198.51.100.24', + Country: 'US', + City: 'Seattle', + ForeignLocation: false, + }, + ], + LocationAnalysis: { + UsageLocation: 'US', + UserRegisteredCountry: 'United States', + SignInCountries: [ + { Country: 'US', Count: 41 }, + { Country: 'NG', Count: 9 }, + ], + ForeignSignInCount: 9, + ForeignSuccessfulSignInCount: 8, + ForeignRuleChangeCount: 1, + ForeignSafelistChangeCount: 1, + ForeignSharingChangeCount: 1, + ForeignSentMessageCount: 1, + Note: null, + }, }, } diff --git a/src/components/CippPdf/reportPdfPrimitives.jsx b/src/components/CippPdf/reportPdfPrimitives.jsx index c671983dba19..94049897534a 100644 --- a/src/components/CippPdf/reportPdfPrimitives.jsx +++ b/src/components/CippPdf/reportPdfPrimitives.jsx @@ -1,6 +1,6 @@ import { Children } from 'react' import { Text, View, Image, Page } from '@react-pdf/renderer' -import { REPORT_COLOURS, applyReportVariables } from './reportTheme' +import { REPORT_COLOURS, applyFooterText, applyWatermarkText } from './reportTheme' import { useReport, useReportStyles } from './reportContext' import { DEFAULT_PAGE_SETUP, TABLE_ROW_PADDING, contentWidth } from './reportPdfStyles' import { wrapLongTokens } from './measureText' @@ -124,7 +124,7 @@ export const ContentPage = ({ title, subtitle, children, ...props }) => { */ export const PageFooter = ({ styles, label, theme, variables }) => { const templated = theme?.footer?.enabled - ? applyReportVariables(theme.footer.template, variables) + ? applyFooterText(theme.footer.template, variables) : '' // Configured branding wins over the report's own label. The reverse — which this did at first — // meant every report that passed a label silently ignored the footer text an MSP had set, which @@ -178,8 +178,16 @@ export const ReportPage = ({ ) -export const Watermark = ({ styles, theme, text, onDark = false }) => { - const value = text ?? (theme?.watermark?.enabled ? theme.watermark.text : '') +/** + * Diagonal mark drawn over every page. Same `%variable%` substitution as the footer — branding + * stores a template (e.g. `%tenantname%`), and the report fills it from the surrounding context. + * The 40-character ceiling is applied to the *resolved* string, after variables expand. + */ +export const Watermark = ({ styles, theme, text, variables: variablesProp, onDark = false }) => { + const report = useReport() + const variables = variablesProp ?? report.variables + const template = text ?? (theme?.watermark?.enabled ? theme.watermark.text : '') + const value = template ? applyWatermarkText(template, variables) : '' if (!value) return null return ( diff --git a/src/components/CippPdf/reportTheme.js b/src/components/CippPdf/reportTheme.js index 6e288ae766c7..c2443b55d26a 100644 --- a/src/components/CippPdf/reportTheme.js +++ b/src/components/CippPdf/reportTheme.js @@ -169,6 +169,12 @@ const buildSeries = (primary, secondary) => { const DEFAULT_FOOTER_TEMPLATE = '' const DEFAULT_WATERMARK_TEXT = '' +/** Hard ceiling for page/cover footer text — applied after `%variable%` substitution. */ +export const FOOTER_MAX_LENGTH = 200 + +/** Hard ceiling for the mark drawn on the page — applied after `%variable%` substitution. */ +export const WATERMARK_MAX_LENGTH = 40 + /** * The parts of a report that can be coloured independently. * @@ -333,6 +339,23 @@ export const applyReportVariables = (template, variables = {}) => { }) } +/** + * Resolve a watermark template and enforce the on-page length ceiling. + * + * The branding field stores a template (and rejects templates over the same limit). Tenant names + * and other variables can still expand past it at render time — that is when the ceiling is + * applied, so a long `%tenantname%` cannot spill a mark across the whole page. + */ +export const applyWatermarkText = (template, variables = {}) => + applyReportVariables(template, variables).slice(0, WATERMARK_MAX_LENGTH) + +/** + * Resolve page-footer / cover-note text and enforce the length ceiling after substitution. + * Same reason as the watermark: a long `%tenantname%` must not blow past the stored limit. + */ +export const applyFooterText = (template, variables = {}) => + applyReportVariables(template, variables).slice(0, FOOTER_MAX_LENGTH) + /** * Build the theme a report renders against. * diff --git a/src/components/CippSettings/CippBrandingCoverPreview.jsx b/src/components/CippSettings/CippBrandingCoverPreview.jsx index a4c016ad2b14..3dc4b86c4a8f 100644 --- a/src/components/CippSettings/CippBrandingCoverPreview.jsx +++ b/src/components/CippSettings/CippBrandingCoverPreview.jsx @@ -1,7 +1,7 @@ import { Box, Typography } from "@mui/material"; import { resolveCoverImage } from "../CippPdf/resolveCoverImage"; import { createReportStyles } from "../CippPdf/reportPdfStyles"; -import { createReportTheme } from "../CippPdf/reportTheme"; +import { applyFooterText, applyWatermarkText, createReportTheme } from "../CippPdf/reportTheme"; import { SAMPLE_BEC, SAMPLE_PERMISSIONS, @@ -37,6 +37,8 @@ export const REPORT_COVER_PRESETS = [ { id: "executive", label: "Executive Report", + // Must match `reportName` on ExecutiveReportDocument — cover-mock `%reportname%` uses this. + reportName: "Executive Summary", coverLabel: "Security Assessment", title: "Executive", accent: "Summary", @@ -48,6 +50,7 @@ export const REPORT_COVER_PRESETS = [ { id: "shadowAI", label: "Shadow AI Report", + reportName: "Shadow AI Report", coverLabel: "AI Risk Assessment", title: "Shadow AI", accent: "Report", @@ -60,6 +63,7 @@ export const REPORT_COVER_PRESETS = [ { id: "bec", label: "BEC Remediation", + reportName: "BEC Analysis Report", coverLabel: "Security Incident Report", title: "BEC Compromise", accent: "Analysis", @@ -74,6 +78,7 @@ export const REPORT_COVER_PRESETS = [ { id: "sharing", label: "Sharing Report", + reportName: "Sharing Report", coverLabel: "Data Sharing Review", title: "Sharing", accent: "Report", @@ -88,6 +93,7 @@ export const REPORT_COVER_PRESETS = [ { id: "permissions", label: "Permissions Report", + reportName: "Permissions Report", coverLabel: "Access Review", title: "Permissions", accent: "Report", @@ -102,6 +108,8 @@ export const REPORT_COVER_PRESETS = [ // the report builder, so it belongs after the reports that are the same every time. id: "reportBuilder", label: "Report Builder", + // Matches the sample template name used by CippBrandingReportPreview for this report type. + reportName: "Quarterly Security Review", coverLabel: "Assessment Report", title: "Custom", accent: "Report", @@ -152,6 +160,18 @@ const CippBrandingCoverPreview = ({ month: "long", day: "numeric", }); + // Same substitution + length ceiling the PDF applies — without it, typing %tenantname% in + // branding shows the token literally in this mock while real reports resolve it. + const previewVariables = { + tenantname: SAMPLE_TENANT_NAME, + reportname: preset.reportName, + reportdate: currentDate, + }; + const watermarkLabel = applyWatermarkText(theme.watermark.text, previewVariables); + const coverFooterLabel = applyFooterText( + theme.coverFooterText || preset.footer, + previewVariables + ); return ( - {theme.watermark.text} + {watermarkLabel} )} @@ -339,7 +359,7 @@ const CippBrandingCoverPreview = ({ }} > {/* A configured cover note replaces the report's own wording, exactly as the PDF does. */} - {theme.coverFooterText || preset.footer} + {coverFooterLabel} diff --git a/src/components/CippSettings/CippBrandingSettings.jsx b/src/components/CippSettings/CippBrandingSettings.jsx index c798c9afbcf0..6a01076a5a58 100644 --- a/src/components/CippSettings/CippBrandingSettings.jsx +++ b/src/components/CippSettings/CippBrandingSettings.jsx @@ -33,7 +33,7 @@ import { normalizeLogoImageIds, normalizeLogoUploads, } from "../CippPdf/resolveCoverImage"; -import { REPORT_COLOUR_ROLES } from "../CippPdf/reportTheme"; +import { FOOTER_MAX_LENGTH, REPORT_COLOUR_ROLES, WATERMARK_MAX_LENGTH } from "../CippPdf/reportTheme"; import { BRANDING_GALLERY_QUERY_KEY } from "../CippPdf/useBrandingSettings"; import { useForm } from "react-hook-form"; @@ -78,7 +78,7 @@ const FOOTER_TOOLTIP = "Text shown at the bottom of every report page. Type % for CIPP's variables, plus %reportname% and %reportdate% which reports add. Report templates can override this or switch it off individually."; const WATERMARK_TOOLTIP = - "Diagonal text drawn faintly across every page of a report, cover included — e.g. DRAFT or CONFIDENTIAL. Typing text is enough to show it; the toggle only exists to switch it off without losing the wording."; + "Diagonal text drawn faintly across every page of a report, cover included. Type % for CIPP's variables (e.g. %tenantname%), or a static mark such as DRAFT. Typing text is enough to show it; the toggle only exists to switch it off without losing the wording."; const REPORT_DEFAULTS_TOOLTIP = "Which preset each report reaches for when nothing else says otherwise. A report template with its own preset still wins over this, and this still wins over the default branding above."; @@ -1412,12 +1412,12 @@ const CippBrandingSettings = () => { name="footerText" formControl={formControl} placeholder="%tenantname% — prepared by Contoso IT — %reportdate%" - helperText="Type % for variables. Reports add %reportname% and %reportdate%." + helperText={`Type % for variables. Reports add %reportname% and %reportdate%. After substitution, text is capped at ${FOOTER_MAX_LENGTH} characters.`} includeSystemVariables={true} validators={{ maxLength: { - value: 200, - message: "Footer text must be 200 characters or fewer", + value: FOOTER_MAX_LENGTH, + message: `Footer text must be ${FOOTER_MAX_LENGTH} characters or fewer`, }, }} /> @@ -1426,13 +1426,13 @@ const CippBrandingSettings = () => { name="coverFooterText" label="Cover Note" placeholder="Blank = each report's own wording" - helperText="Replaces the confidentiality note on cover pages" + helperText={`Replaces the confidentiality note on cover pages. After substitution, text is capped at ${FOOTER_MAX_LENGTH} characters.`} includeSystemVariables={true} formControl={formControl} validators={{ maxLength: { - value: 200, - message: "Cover note must be 200 characters or fewer", + value: FOOTER_MAX_LENGTH, + message: `Cover note must be ${FOOTER_MAX_LENGTH} characters or fewer`, }, }} /> @@ -1462,14 +1462,16 @@ const CippBrandingSettings = () => { diff --git a/src/components/CippTable/CippQueueTracker.js b/src/components/CippTable/CippQueueTracker.js index 20a4fd6d62cd..ac35062aa3c3 100644 --- a/src/components/CippTable/CippQueueTracker.js +++ b/src/components/CippTable/CippQueueTracker.js @@ -34,9 +34,10 @@ export const CippQueueTracker = ({ queueId, queryKey, title, onQueueComplete }) data: { QueueId: effectiveQueueId }, queryKey: `CippQueue-${effectiveQueueId || "unknown"}`, waiting: shouldShowQueue && !!effectiveQueueId && !isQueueCompleted, - refetchInterval: (data) => { - // Check if the current data shows completion - const currentData = data?.[0]; + refetchInterval: (query) => { + // TanStack Query v5 hands this callback the Query object, not the data - the response + // has to be read off query.state or the completion check below never matches. + const currentData = query?.state?.data?.[0]; const isCurrentCompleted = currentData?.Status === "Completed" || currentData?.Status === "Failed" || diff --git a/src/data/M365Licenses.json b/src/data/M365Licenses.json index a5075fa9e6ef..61bd872fbf3b 100644 --- a/src/data/M365Licenses.json +++ b/src/data/M365Licenses.json @@ -38287,6 +38287,14 @@ "Service_Plan_Id": "65cc641f-cccd-4643-97e0-a17e3045e541", "Service_Plans_Included_Friendly_Names": "Microsoft Records Management" }, + { + "Product_Display_Name": "Office 365 E5", + "String_Id": "ENTERPRISEPREMIUM", + "GUID": "c7df2760-2c81-4ef7-b578-5b5392b571df", + "Service_Plan_Name": "MICROSOFT_TEAMS_EVENTS", + "Service_Plan_Id": "29c62f1c-8ffc-4304-9cb9-398a6aa1852b", + "Service_Plans_Included_Friendly_Names": "Microsoft Teams Events" + }, { "Product_Display_Name": "Office 365 E5 EEA (no Teams)", "String_Id": "Office_365_w/o_Teams_Bundle_E5", @@ -47918,5 +47926,61 @@ "Service_Plan_Name": "INSIDER_RISK_MANAGEMENT_FOR_AGENTS", "Service_Plan_Id": "004ddfc0-c92f-4b0a-90c5-c60646299d71", "Service_Plans_Included_Friendly_Names": "Microsoft Purview Insider Risk Management for Agents" + }, + { + "Product_Display_Name": "Microsoft Teams Premium", + "String_Id": "M365_TEAMS_PREMIUM", + "GUID": "6432c818-bcef-43b6-9290-aec052964950", + "Service_Plan_Name": "TEAMSPRO_MGMT", + "Service_Plan_Id": "0504111f-feb8-4a3c-992a-70280f9a2869", + "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Intelligent" + }, + { + "Product_Display_Name": "Microsoft Teams Premium", + "String_Id": "M365_TEAMS_PREMIUM", + "GUID": "6432c818-bcef-43b6-9290-aec052964950", + "Service_Plan_Name": "TEAMSPRO_CUST", + "Service_Plan_Id": "cc8c0802-a325-43df-8cba-995d0c6cb373", + "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Personalized" + }, + { + "Product_Display_Name": "Microsoft Teams Premium", + "String_Id": "M365_TEAMS_PREMIUM", + "GUID": "6432c818-bcef-43b6-9290-aec052964950", + "Service_Plan_Name": "TEAMSPRO_PROTECTION", + "Service_Plan_Id": "f8b44f54-18bb-46a3-9658-44ab58712968", + "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Secure" + }, + { + "Product_Display_Name": "Microsoft Teams Premium", + "String_Id": "M365_TEAMS_PREMIUM", + "GUID": "6432c818-bcef-43b6-9290-aec052964950", + "Service_Plan_Name": "TEAMSPRO_VIRTUALAPPT", + "Service_Plan_Id": "9104f592-f2a7-4f77-904c-ca5a5715883f", + "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Virtual Appointment" + }, + { + "Product_Display_Name": "Microsoft Teams Premium", + "String_Id": "M365_TEAMS_PREMIUM", + "GUID": "6432c818-bcef-43b6-9290-aec052964950", + "Service_Plan_Name": "MCO_VIRTUAL_APPT", + "Service_Plan_Id": "711413d0-b36e-4cd4-93db-0a50a4ab7ea3", + "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Virtual Appointments" + }, + { + "Product_Display_Name": "Microsoft Teams Premium", + "String_Id": "M365_TEAMS_PREMIUM", + "GUID": "6432c818-bcef-43b6-9290-aec052964950", + "Service_Plan_Name": "QUEUES_APP", + "Service_Plan_Id": "ab2d4fb5-f80a-4bf1-a11d-7f1da254041b", + "Service_Plans_Included_Friendly_Names": "Queues app for Microsoft Teams" + }, + { + "Product_Display_Name": "Skype for Business PSTN Domestic and International Calling", + "String_Id": "MCOSMS2", + "GUID": "d4009785-b899-4cab-97b6-d06a7c799507", + "Service_Plan_Name": "MCOSMS2", + "Service_Plan_Id": "d4009785-b899-4cab-97b6-d06a7c799507", + "Service_Plans_Included_Friendly_Names": "DOMESTIC AND INTERNATIONAL CALLING PLAN" } ] diff --git a/src/data/standards.json b/src/data/standards.json index 560a9eceebe0..62a8932f623d 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -4,7 +4,7 @@ "cat": "Copilot (M365) Standards", "tag": [], "helpText": "Configures Microsoft 365 Copilot tenant policy settings: Copilot Chat pinning, blocking Copilot access to open content, Designer image generation, web search, and admin-center Copilot. Each setting can be left unconfigured, enabled, or disabled. These settings are managed through the Copilot policy service (Cloud Policy / Intune) and are applied at the tenant level.", - "docsDescription": "Manages Microsoft 365 Copilot admin policy settings via the `/copilot/admin/policySettings` Microsoft Graph API (beta). Each of the five supported settings can be independently set or left unmanaged using the \"Do not configure\" option. NOTE: this API currently requires delegated authentication and supports only tenant-level policies; settings scoped to group-level policies return an error and are skipped. The exact accepted value per setting is a string (commonly \"1\"/\"0\") and should be validated against a Copilot-licensed tenant.", + "docsDescription": "Manages Microsoft 365 Copilot admin policy settings via the `/copilot/admin/policySettings` Microsoft Graph API (beta). Each of the five supported settings can be independently set or left unmanaged using the \"Do not configure\" option. NOTE: this API currently requires delegated authentication and supports only tenant-level policies; settings scoped to group-level policies return an error and are skipped. Values are strings whose meaning is per-setting, not uniform: web search is three-state (\"0\" enabled everywhere, \"1\" disabled everywhere, \"2\" disabled in Copilot Work mode only) and Designer image generation is inverted (\"1\" disables it, \"0\" enables it). Graph treats these as opaque strings and validates nothing, so do not assume 1=on/0=off for a setting you have not verified against a Copilot-licensed tenant.", "executiveText": "Provides centralized governance of Microsoft 365 Copilot capabilities across the organization. Administrators can control whether Copilot Chat is pinned for users, whether Copilot can access open files, and whether features such as image generation and web search are available, helping balance employee productivity with data governance and compliance requirements.", "addedComponent": [ { @@ -51,11 +51,11 @@ "name": "standards.CopilotSettings.allowWebSearch", "options": [ { "label": "Do not configure", "value": "donotconfigure" }, - { "label": "Enabled in Microsoft 365 Copilot and Microsoft 365 Copilot Chat", "value": "2" }, + { "label": "Enabled in Microsoft 365 Copilot and Microsoft 365 Copilot Chat", "value": "0" }, { "label": "Disabled in Microsoft 365 Copilot and Microsoft 365 Copilot Chat", "value": "1" }, { "label": "Disabled in Microsoft 365 Copilot Work mode, Enabled in Microsoft 365 Copilot Chat", - "value": "0" + "value": "2" } ] }, @@ -7597,7 +7597,7 @@ "impact": "High Impact", "impactColour": "danger", "addedDate": "2026-04-28", - "powershellEquivalent": "Set-SPOTenant -CustomScriptsRestrictMode $true", + "powershellEquivalent": "Portal only", "recommendedBy": ["CIPP"], "requiredCapabilities": [ "SHAREPOINTWAC", diff --git a/src/pages/copilot/settings/index.js b/src/pages/copilot/settings/index.js index 0f8c018b2e99..67cf5c246515 100644 --- a/src/pages/copilot/settings/index.js +++ b/src/pages/copilot/settings/index.js @@ -14,7 +14,11 @@ const Page = () => { url: '/api/ExecCopilotSettings', icon: , data: { settingId: 'settingId' }, - condition: (row) => row.settingId !== 'microsoft.copilot.allowwebsearch', + condition: (row) => + ![ + 'microsoft.copilot.allowwebsearch', + 'microsoft.copilot.imagegeneration', + ].includes(row.settingId), fields: [ { type: 'autoComplete', @@ -32,6 +36,31 @@ const Page = () => { confirmText: "Set '[setting]' to the selected state?", relatedQueryKeys: [queryKey], }, + { + // Designer image generation inverts the usual toggle: '1' disables, '0' enables. + label: 'Set Status', + type: 'POST', + url: '/api/ExecCopilotSettings', + icon: , + data: { settingId: 'settingId' }, + condition: (row) => row.settingId === 'microsoft.copilot.imagegeneration', + fields: [ + { + type: 'autoComplete', + name: 'value', + label: 'Desired state', + multiple: false, + creatable: false, + options: [ + { label: 'Enabled', value: '0' }, + { label: 'Disabled', value: '1' }, + { label: 'Not configured', value: 'clear' }, + ], + }, + ], + confirmText: "Set '[setting]' to the selected state?", + relatedQueryKeys: [queryKey], + }, { // Web search is a three-state policy; its values match the config.office.com options label: 'Set Status', @@ -51,7 +80,7 @@ const Page = () => { { label: 'Enabled in Microsoft 365 Copilot and Microsoft 365 Copilot Chat', - value: '2', + value: '0', }, { label: @@ -61,7 +90,7 @@ const Page = () => { { label: 'Disabled in Microsoft 365 Copilot Work mode, Enabled in Microsoft 365 Copilot Chat', - value: '0', + value: '2', }, { label: 'Not configured', value: 'clear' }, ], diff --git a/src/pages/identity/administration/users/user/bec.jsx b/src/pages/identity/administration/users/user/bec.jsx index d63a7fcac20f..faac9f2e1dc5 100644 --- a/src/pages/identity/administration/users/user/bec.jsx +++ b/src/pages/identity/administration/users/user/bec.jsx @@ -106,17 +106,26 @@ const Page = () => { } } - if (becPollingCall.isSuccess && becPollingCall.data && !becPollingCall.data?.Waiting) { + // The !restart guard keeps a refresh from being cancelled: between clicking Refresh Data + // and the overwrite call resolving, the polling cache still holds the previous run, which + // would otherwise read as "done" and stop the loading state. + if (!restart && becPollingCall.isSuccess && becPollingCall.data && !becPollingCall.data?.Waiting) { setIsLoading(false) } }, [becPollingCall.dataUpdatedAt, becInitialCall]) const restartProcess = () => { setRestart(true) - becPollingCall.refetch() + setIsLoading(true) + // The 500ms lets the re-render register Overwrite on the initial call's params. Poll only + // after the initial call resolves: the backend resets the cache row to Waiting before it + // responds, so a poll issued after that cannot race the reset and resurface the old run. setTimeout(() => { - becInitialCall.refetch() - becPollingCall.refetch() + becInitialCall.refetch().finally(() => { + // one-shot: without this every later refetch would force a fresh run + setRestart(false) + becPollingCall.refetch() + }) }, 500) } @@ -150,21 +159,23 @@ const Page = () => { const getUserMessage = () => { if (!becPollingCall.data) return null if (becPollingCall.data.NewUsers && becPollingCall.data.NewUsers.length > 0) { - return 'New users have been found in the last 14 days. Please review the list below and take action as needed.' + return 'New users have been found in the last 7 days. Please review the list below and take action as needed.' } return 'No new users found.' } const getAppMessage = () => { if (!becPollingCall.data) return null + const maliciousAddedCount = (becPollingCall.data.AddedApps || []).filter( + (app) => app?.MaliciousMatch + ).length + const maliciousPresentCount = becPollingCall.data.MaliciousSPs?.length || 0 + if (maliciousAddedCount > 0 || maliciousPresentCount > 0) { + return `Potential Breach found: ${ + maliciousAddedCount + maliciousPresentCount + } application(s) in this tenant match the CIPP known-malicious application catalog. Consent-based access survives a password reset, so remove these applications unless their presence is explained.` + } if (becPollingCall.data.AddedApps && becPollingCall.data.AddedApps.length > 0) { - // Example condition to check for potential breach - const hasPotentialBreach = becPollingCall.data.AddedApps.some( - (app) => /* your condition here */ false - ) - if (hasPotentialBreach) { - return 'Potential Breach found.' - } return 'New applications have been found. Please review the list below and take action as needed.' } return 'No new applications found.' @@ -172,11 +183,13 @@ const Page = () => { const getMailboxPermissionMessage = () => { if (!becPollingCall.data) return null - if ( - becPollingCall.data.MailboxPermissionChanges && - becPollingCall.data.MailboxPermissionChanges.length > 0 - ) { - return 'Mailbox permission changes have been found.' + const changes = becPollingCall.data.MailboxPermissionChanges || [] + if (changes.length > 0) { + const targeting = changes.filter((c) => c?.TargetsSuspect === true).length + if (targeting > 0) { + return `${changes.length} mailbox permission change(s) found across the tenant in the last 7 days, ${targeting} of which target this mailbox. Review those first.` + } + return `${changes.length} mailbox permission change(s) found across the tenant in the last 7 days. None appear to target this mailbox, but verify the list below.` } return 'No mailbox permission changes found.' } @@ -184,13 +197,38 @@ const Page = () => { const getSentMessagesMessage = () => { if (!becPollingCall.data) return null if (becPollingCall.data.SentMessages && becPollingCall.data.SentMessages.length > 0) { - return 'Sent messages have been found. Please review the list below for any suspicious activity.' + const analysis = becPollingCall.data.SentMessageAnalysis + const parts = [ + `${analysis?.TotalMessages ?? becPollingCall.data.SentMessages.length} message(s) to ${ + analysis?.TotalRecipients ?? becPollingCall.data.SentMessages.length + } recipient(s) were sent in the last 7 days`, + ] + if (analysis?.FlaggedSubjectCount > 0) { + parts.push( + `${analysis.FlaggedSubjectCount} subject(s) were sent as many separate messages or to many recipients — identical-subject mass mail is a classic sign of a compromised mailbox running a campaign` + ) + } + if (analysis?.Bursts?.length > 0) { + parts.push( + `${analysis.Bursts.length} short burst(s) of high-volume sending were detected` + ) + } + const foreignCount = becPollingCall.data.LocationAnalysis?.ForeignSentMessageCount || 0 + if (foreignCount > 0) { + parts.push( + `${foreignCount} message(s) were sent from an IP outside the user's assigned usage location` + ) + } + return `${parts.join('. ')}. Please review the list below for any suspicious activity.` } return 'No sent messages found in the specified time range.' } const getSafelistMessage = () => { if (!becPollingCall.data) return null + if (becPollingCall.data.SafelistError) { + return `${becPollingCall.data.SafelistError} An empty list here is not proof the mailbox has none — refresh after fixing the underlying problem.` + } const trustedCount = becPollingCall.data.TrustedSenders?.length || 0 const blockedCount = becPollingCall.data.BlockedSenders?.length || 0 const changeCount = becPollingCall.data.SafelistChanges?.length || 0 @@ -217,7 +255,9 @@ const Page = () => { [becPollingCall.data] ) - const intuneDevicesWindowStart = useMemo(() => { + // the analysis window: 7 days before the data was extracted. Shared by the Intune + // enrollment and MFA registration recency checks. + const analysisWindowStart = useMemo(() => { const extractedAt = becPollingCall.data?.ExtractedAt ? new Date(becPollingCall.data.ExtractedAt) : new Date() @@ -227,6 +267,29 @@ const Page = () => { return new Date(extractedAt.getTime() - 7 * 24 * 60 * 60 * 1000) }, [becPollingCall.data?.ExtractedAt]) + const recentMfaDeviceCount = useMemo( + () => + (becPollingCall.data?.MFADevices || []).filter((method) => { + if (!method?.createdDateTime) return false + const created = new Date(method.createdDateTime) + if (Number.isNaN(created.getTime())) return false + return created >= analysisWindowStart + }).length, + [becPollingCall.data?.MFADevices, analysisWindowStart] + ) + + const foreignActivityCount = useMemo(() => { + const analysis = becPollingCall.data?.LocationAnalysis + if (!analysis) return 0 + return ( + (analysis.ForeignSignInCount || 0) + + (analysis.ForeignRuleChangeCount || 0) + + (analysis.ForeignSafelistChangeCount || 0) + + (analysis.ForeignSharingChangeCount || 0) + + (analysis.ForeignSentMessageCount || 0) + ) + }, [becPollingCall.data?.LocationAnalysis]) + const intuneDevices = useMemo(() => { const devices = [...(becPollingCall.data?.IntuneDevices || [])] devices.sort((a, b) => { @@ -243,9 +306,9 @@ const Page = () => { if (!device?.enrolledDateTime) return false const enrolled = new Date(device.enrolledDateTime) if (Number.isNaN(enrolled.getTime())) return false - return enrolled >= intuneDevicesWindowStart + return enrolled >= analysisWindowStart }).length, - [intuneDevices, intuneDevicesWindowStart] + [intuneDevices, analysisWindowStart] ) const intuneDeviceActions = useMemo( @@ -253,6 +316,91 @@ const Page = () => { [userSettingsDefaults.currentTenant] ) + const getMfaMessage = () => { + if (!becPollingCall.data) return null + const count = becPollingCall.data.MFADevices?.length || 0 + if (count === 0) { + return 'No MFA methods are registered for this user. If MFA was expected, an attacker may have removed it; either way the account currently has no second factor.' + } + if (recentMfaDeviceCount > 0) { + return `${count} MFA method(s) registered, ${recentMfaDeviceCount} 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.` + } + return `${count} MFA method(s) registered. Please review the list below and take action as required.` + } + + const getSignInLocationMessage = () => { + if (!becPollingCall.data) return null + if (becPollingCall.data.SuspectUserSignInsError) { + return `${becPollingCall.data.SuspectUserSignInsError} This is not proof the user has no sign-ins — fix the underlying permission or licensing problem and refresh.` + } + const analysis = becPollingCall.data.LocationAnalysis + const signInCount = becPollingCall.data.SuspectUserSignIns?.length || 0 + if (signInCount === 0) { + return 'No sign-ins were found for this user in the sign-in logs.' + } + const countries = (analysis?.SignInCountries || []) + .map((c) => `${c.Country} (${c.Count})`) + .join(', ') + if (!analysis?.UsageLocation) { + return `${ + analysis?.Note || + 'The user has no usage location assigned in Entra ID, so activity cannot be compared against an expected country.' + } Sign-in countries seen: ${countries || 'none recorded'}.` + } + const foreignParts = [] + if (analysis.ForeignSignInCount > 0) { + foreignParts.push( + `${analysis.ForeignSignInCount} sign-in(s), of which ${ + analysis.ForeignSuccessfulSignInCount || 0 + } succeeded (failed foreign attempts are mostly password-spray noise)` + ) + } + if (analysis.ForeignRuleChangeCount > 0) { + foreignParts.push(`${analysis.ForeignRuleChangeCount} inbox rule change(s)`) + } + if (analysis.ForeignSafelistChangeCount > 0) { + foreignParts.push(`${analysis.ForeignSafelistChangeCount} safelist change(s)`) + } + if (analysis.ForeignSharingChangeCount > 0) { + foreignParts.push(`${analysis.ForeignSharingChangeCount} sharing change(s)`) + } + if (analysis.ForeignSentMessageCount > 0) { + foreignParts.push(`${analysis.ForeignSentMessageCount} sent message(s)`) + } + if (foreignParts.length > 0) { + return `The user's assigned usage location is ${ + analysis.UsageLocation + }, but activity originated outside it: ${foreignParts.join( + ', ' + )}. Sign-in countries seen: ${countries}. Review the sign-ins below and the flagged rows in the checks above.` + } + return `All located activity matches the user's assigned usage location (${ + analysis.UsageLocation + }). Sign-in countries seen: ${countries || 'none recorded'}.` + } + + const getSharingMessage = () => { + if (!becPollingCall.data) return null + const changes = becPollingCall.data.SharingChanges || [] + if (changes.length === 0) { + return 'No sharing links were created or changed by this account in the last 7 days.' + } + const anonymousCount = changes.filter((c) => c?.Operation?.startsWith('AnonymousLink')).length + const foreignCount = becPollingCall.data.LocationAnalysis?.ForeignSharingChangeCount || 0 + const parts = [ + `${changes.length} OneDrive/SharePoint sharing change(s) found in the last 7 days`, + ] + if (anonymousCount > 0) { + parts.push(`${anonymousCount} involve anonymous links, which anyone with the URL can open`) + } + if (foreignCount > 0) { + parts.push(`${foreignCount} were made from outside the user's usage location`) + } + return `${parts.join( + '. ' + )}. Attackers share folders to keep pulling data after a password reset — review each link and remove any that are not explained.` + } + const getIntuneDevicesMessage = () => { if (!becPollingCall.data) return null if (becPollingCall.data.IntuneDevicesError) { @@ -425,10 +573,16 @@ const Page = () => { ))} @@ -465,7 +619,10 @@ const Page = () => { {/* Check 3: New Applications */} {getAppMessage()} @@ -473,17 +630,48 @@ const Page = () => { {becPollingCall.data?.AddedApps?.length > 0 && ( - {becPollingCall.data.AddedApps.map((app, index) => ( - - ))} + {[...becPollingCall.data.AddedApps] + .sort((a, b) => !!b?.MaliciousMatch - !!a?.MaliciousMatch) + .map((app, index) => ( + + ))} )} + {becPollingCall.data?.MaliciousSPs?.length > 0 && ( + + + Known-malicious applications present in the tenant (any age) + + + + {becPollingCall.data.MaliciousSPs.map((app, index) => ( + + ))} + + + + )} {/* Check 4: Mailbox permission changes */} @@ -497,14 +685,20 @@ const Page = () => { {becPollingCall.data?.MailboxPermissionChanges?.length > 0 && ( - {becPollingCall.data.MailboxPermissionChanges.map((permission, index) => ( - - ))} + {[...becPollingCall.data.MailboxPermissionChanges] + .sort((a, b) => (b?.TargetsSuspect === true) - (a?.TargetsSuspect === true)) + .map((permission, index) => ( + + ))} )} @@ -518,6 +712,52 @@ const Page = () => { {getSentMessagesMessage()} + {becPollingCall.data?.SentMessageAnalysis?.RepeatedSubjects?.length > 0 && ( + + + Repeated subjects + + + + {becPollingCall.data.SentMessageAnalysis.RepeatedSubjects.map( + (group, index) => ( + + ) + )} + + + + )} + {becPollingCall.data?.SentMessageAnalysis?.Bursts?.length > 0 && ( + + + Send bursts + + + + {becPollingCall.data.SentMessageAnalysis.Bursts.map((burst, index) => ( + + ))} + + + + )} {becPollingCall.data?.SentMessages?.length > 0 && ( { hideTitle={true} title="Sent Messages" data={becPollingCall.data.SentMessages} - simpleColumns={['Subject', 'RecipientAddress', 'Status', 'Received', 'FromIP']} + simpleColumns={[ + 'Subject', + 'RecipientAddress', + 'Status', + 'Received', + 'FromIP', + 'Country', + ]} /> )} @@ -536,21 +783,34 @@ const Page = () => { count={becPollingCall.data?.MFADevices?.length || 0} > - MFA Devices have been found. Please review the list below and take action as - required + {getMfaMessage()} {becPollingCall.data?.MFADevices?.length > 0 && ( - {becPollingCall.data.MFADevices.map((permission, index) => ( - - ))} + {[...becPollingCall.data.MFADevices] + .sort( + (a, b) => + new Date(b?.createdDateTime || 0) - new Date(a?.createdDateTime || 0) + ) + .map((method, index) => { + const isRecent = + method?.createdDateTime && + new Date(method.createdDateTime) >= analysisWindowStart + return ( + + ) + })} )} @@ -584,12 +844,18 @@ const Page = () => { - + {getSafelistMessage()} {senderRows.length > 0 && ( @@ -614,8 +880,16 @@ const Page = () => { @@ -662,6 +936,71 @@ const Page = () => { )} + {/* Check 10: Sign-in Locations */} + + + {getSignInLocationMessage()} + + {becPollingCall.data?.SuspectUserSignIns?.length > 0 && ( + + + + )} + + + {/* Check 11: Sharing Links */} + + + {getSharingMessage()} + + {becPollingCall.data?.SharingChanges?.length > 0 && ( + + + + )} + + {/* Report Data */} diff --git a/src/pages/identity/administration/users/user/exchange.jsx b/src/pages/identity/administration/users/user/exchange.jsx index a87124466131..f2ef68719bcd 100644 --- a/src/pages/identity/administration/users/user/exchange.jsx +++ b/src/pages/identity/administration/users/user/exchange.jsx @@ -801,26 +801,16 @@ const Page = () => { icon: , url: '/api/ExecModifyCalPerms', customDataformatter: (row, action, formData) => { - var permissions = [] - if (Array.isArray(row)) { - row.forEach((item) => { - const originalUser = item._raw ? item._raw.User : item.User - permissions.push({ - UserID: originalUser, // Use original identifier for API calls - PermissionLevel: item.AccessRights, - FolderName: item.FolderName, - Modification: 'Remove', - }) - }) - } else { - const originalUser = row._raw ? row._raw.User : row.User - permissions.push({ - UserID: originalUser, // Use original identifier for API calls - PermissionLevel: row.AccessRights, - FolderName: row.FolderName, - Modification: 'Remove', - }) - } + const rows = Array.isArray(row) ? row : [row] + // UserId is the resolved recipient; User is only a display + // name, which Exchange cannot resolve when two share it. + const permissions = rows.map((item) => ({ + UserID: item._raw?.UserId || item._raw?.User || item.User, + DisplayName: item._raw?.User || item.User, + PermissionLevel: item.AccessRights, + FolderName: item.FolderName, + Modification: 'Remove', + })) return { userID: graphUserRequest.data?.[0]?.userPrincipalName, tenantFilter: userSettingsDefaults.currentTenant, @@ -870,7 +860,8 @@ const Page = () => { tenantFilter: userSettingsDefaults.currentTenant, permissions: [ { - UserID: originalUser, // Use original identifier for API calls + UserID: data._raw?.UserId || originalUser, + DisplayName: originalUser, PermissionLevel: data.AccessRights, FolderName: data.FolderName, Modification: 'Remove', @@ -944,26 +935,16 @@ const Page = () => { icon: , url: '/api/ExecModifyContactPerms', customDataformatter: (row, action, formData) => { - var permissions = [] - if (Array.isArray(row)) { - row.forEach((item) => { - const originalUser = item._raw ? item._raw.User : item.User - permissions.push({ - UserID: originalUser, // Use original identifier for API calls - PermissionLevel: item.AccessRights, - FolderName: item.FolderName, - Modification: 'Remove', - }) - }) - } else { - const originalUser = row._raw ? row._raw.User : row.User - permissions.push({ - UserID: originalUser, // Use original identifier for API calls - PermissionLevel: row.AccessRights, - FolderName: row.FolderName, - Modification: 'Remove', - }) - } + const rows = Array.isArray(row) ? row : [row] + // UserId is the resolved recipient; User is only a display + // name, which Exchange cannot resolve when two share it. + const permissions = rows.map((item) => ({ + UserID: item._raw?.UserId || item._raw?.User || item.User, + DisplayName: item._raw?.User || item.User, + PermissionLevel: item.AccessRights, + FolderName: item.FolderName, + Modification: 'Remove', + })) return { userID: graphUserRequest.data?.[0]?.userPrincipalName, tenantFilter: userSettingsDefaults.currentTenant, @@ -1013,7 +994,8 @@ const Page = () => { tenantFilter: userSettingsDefaults.currentTenant, permissions: [ { - UserID: originalUser, // Use original identifier for API calls + UserID: data._raw?.UserId || originalUser, + DisplayName: originalUser, PermissionLevel: data.AccessRights, FolderName: data.FolderName, Modification: 'Remove', From c6faef7feb3cbe71ff34ae6f44d2dc942b8f3a03 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Wed, 12 Aug 2026 16:02:26 -0400 Subject: [PATCH 04/10] fix: Downgrade Node and jsdom versions in package.json Signed-off-by: John Duprey --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index df7833c3db62..70dece3396fc 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ }, "license": "AGPL-3.0", "engines": { - "node": "^22.22.2" + "node": "^22.22.0" }, "repository": { "type": "git", @@ -135,7 +135,7 @@ "eslint": "^9.39.4", "eslint-config-next": "^16.2.10", "eslint-config-prettier": "^10.1.8", - "jsdom": "30.0.1", + "jsdom": "29.0.1", "msw": "2.12.14", "msw-storybook-addon": "3.0.0", "playwright": "1.59.1", @@ -153,4 +153,4 @@ "resolutions": { "vite": "7.3.6" } -} \ No newline at end of file +} From 775c8c77602c19dcdae933d8f383ff3585e768b6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 14 Aug 2026 21:55:33 +0000 Subject: [PATCH 05/10] Merge pull request #295 from CyberDrain/dev fix: dev to hotfix Synced from CyberDrain/CIPP@674a80db2185e024df0bb9367c347e7e5442f97a --- package.json | 8 +- public/version.json | 2 +- .../CippAllTenants/AllTenantsDashboard.jsx | 3 +- .../CippAllTenants/AllTenantsPrimitives.jsx | 161 ++++++++++++++ .../CippAllTenants/useAllTenantsDashboard.js | 201 ++++++++++++------ .../CippCards/CippSharePointQuotaCard.jsx | 122 +++++++++++ .../CippComponents/CippAutocomplete.jsx | 12 +- .../CippComponents/CippTenantSelector.jsx | 7 +- .../CippTable/CIPPTableToptoolbar.js | 4 +- .../CippWizard/CippAddTenantTypeSelection.jsx | 17 +- src/components/ReleaseNotesDialog.js | 10 +- src/data/standards.json | 6 +- src/pages/cipp/settings/permissions.js | 15 +- .../resources/management/equipment/index.js | 10 +- .../security/reports/cve-report/index.js | 2 +- .../security/safelinks/safelinks/index.jsx | 25 ++- src/pages/teams-share/external-users.js | 8 +- src/pages/teams-share/sharepoint/index.js | 133 ++++++++---- .../AllTenantsCacheList.test.jsx | 83 ++++++++ .../useAllTenantsDashboard.test.js | 159 ++++++++++++++ .../CippComponents/CippBreadcrumbNav.test.jsx | 27 +++ tests/components/ReleaseNotesDialog.test.jsx | 8 +- tests/contexts/tutorial-context.test.jsx | 41 ++++ vitest.config.mjs | 18 ++ 24 files changed, 917 insertions(+), 165 deletions(-) create mode 100644 src/components/CippCards/CippSharePointQuotaCard.jsx create mode 100644 tests/components/CippAllTenants/AllTenantsCacheList.test.jsx create mode 100644 tests/components/CippAllTenants/useAllTenantsDashboard.test.js create mode 100644 tests/components/CippComponents/CippBreadcrumbNav.test.jsx create mode 100644 tests/contexts/tutorial-context.test.jsx diff --git a/package.json b/package.json index 70dece3396fc..4d5b349be97a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cipp", - "version": "10.8.4", + "version": "10.8.5", "author": "CIPP Contributors", "homepage": "https://cipp.app/", "bugs": { @@ -8,7 +8,7 @@ }, "license": "AGPL-3.0", "engines": { - "node": "^22.22.0" + "node": "^22.22.2" }, "repository": { "type": "git", @@ -135,7 +135,7 @@ "eslint": "^9.39.4", "eslint-config-next": "^16.2.10", "eslint-config-prettier": "^10.1.8", - "jsdom": "29.0.1", + "jsdom": "30.0.1", "msw": "2.12.14", "msw-storybook-addon": "3.0.0", "playwright": "1.59.1", @@ -153,4 +153,4 @@ "resolutions": { "vite": "7.3.6" } -} +} \ No newline at end of file diff --git a/public/version.json b/public/version.json index bfb273510e9e..734751ab5a2f 100644 --- a/public/version.json +++ b/public/version.json @@ -1,3 +1,3 @@ { - "version": "10.8.4" + "version": "10.8.5" } \ No newline at end of file diff --git a/src/components/CippAllTenants/AllTenantsDashboard.jsx b/src/components/CippAllTenants/AllTenantsDashboard.jsx index 5ca488bc2ce3..2aeaa1302634 100644 --- a/src/components/CippAllTenants/AllTenantsDashboard.jsx +++ b/src/components/CippAllTenants/AllTenantsDashboard.jsx @@ -14,6 +14,7 @@ import { useAllTenantsDashboard } from './useAllTenantsDashboard' import { AllTenantsBandHeading, AllTenantsBarList, + AllTenantsCacheList, AllTenantsMeterList, AllTenantsRowList, AllTenantsTrendChart, @@ -578,7 +579,7 @@ export const AllTenantsDashboard = () => { - { + const [expanded, setExpanded] = useState(null) + + if (isFetching) { + return ( + + {[0, 1, 2].map((key) => ( + + ))} + + ) + } + + if (!rows.length) { + return ( + + {emptyText} + + ) + } + + return ( + + {rows.map((row, index) => { + const key = row.domain ?? `${row.name}-${index}` + const isOpen = expanded === key + const collections = row.collections ?? [] + const canExpand = collections.length > 0 + + const header = ( + + + + {row.name} + + {row.detail && ( + + {row.detail} + + )} + + {canExpand && ( + <> + + + + )} + + ) + + return ( + + {canExpand ? ( + setExpanded(isOpen ? null : key)} + aria-expanded={isOpen} + sx={{ width: '100%', display: 'block', borderRadius: 1 }} + > + {header} + + ) : ( + header + )} + + + {collections.map((collection) => ( + + + {collection.type} + + + {parseCippDate(collection.lastRefresh).toLocaleString()} ·{' '} + {formatAge(collection.ageHours)} + + + ))} + + + + ) + })} + + ) +} + /** Labelled percentage meters, for pass-rate style measures. */ export const AllTenantsMeterList = ({ meters = [], isFetching }) => { if (isFetching) { diff --git a/src/components/CippAllTenants/useAllTenantsDashboard.js b/src/components/CippAllTenants/useAllTenantsDashboard.js index 09426e63bb0f..39bbbddbe068 100644 --- a/src/components/CippAllTenants/useAllTenantsDashboard.js +++ b/src/components/CippAllTenants/useAllTenantsDashboard.js @@ -103,6 +103,135 @@ const SCALE_TYPES = [ { type: 'ManagedDevices', label: 'Managed devices' }, ] +// Collections the nightly orchestrator deliberately never runs. Start-CIPPDBCacheOrchestrator only +// executes the license groups in Invoke-CIPPDBCacheCollection plus the standalone Mailboxes and +// MFAState tasks; these enumerate every site, library and drive in the estate, so they are populated +// on demand instead (their own report pages, or Settings > Tenants > Refresh CIPPDB Cache). +// +// Their count rows therefore age forever by design. Freshness is the OLDEST collection a tenant has, +// so leaving them in meant one ad-hoc run months ago pinned an otherwise healthy tenant to "stale" +// permanently — on estates that had ever run them, it was all this card reported. +const ADHOC_CACHE_TYPES = new Set([ + 'SharePointSharingLinks', + 'SharePointPermissions', + 'OneDriveRootPermissions', +]) + +// The nightly orchestrator runs at 03:00, so a tenant that collected yesterday is still healthy; +// past 30 hours it has missed a run, and past 72 it has missed three. +const STALE_HOURS = 30 +const CRITICAL_HOURS = 72 + +/** + * Estate-wide scale totals and per-tenant cache freshness from ListDBCache countsOnly rows. Pure so + * the freshness rules can be exercised on their own — the alternative is standing up all seven of + * the dashboard's queries to assert on three integers. + * + * Each stale tenant carries the collections that made it stale, oldest first, so the card can answer + * "which one, and when did it last run" without a second request — the rows are already here. + */ +export const deriveCacheSummary = (rows, tenants) => { + const tenantCount = tenants.length + const totals = new Map() + const collectionsByTenant = new Map() + // Domains whose only rows are ad-hoc collections. They have nothing scheduled to judge, so they + // read as never cached — but saying that flatly would be wrong when a manual sync plainly ran. + const adhocOnly = new Set() + + rows.forEach((row) => { + const type = row?.Type + const count = Number(row?.Count ?? 0) + // Scale totals still count every collection — only the age judgement is scoped. + if (type) totals.set(type, (totals.get(type) ?? 0) + count) + if (!row?.Tenant) return + if (ADHOC_CACHE_TYPES.has(type)) { + adhocOnly.add(row.Tenant) + return + } + + const ageHours = hoursSince(row?.LastRefresh) + if (ageHours === null) return + const collections = collectionsByTenant.get(row.Tenant) ?? [] + collections.push({ type, lastRefresh: row.LastRefresh, ageHours }) + collectionsByTenant.set(row.Tenant, collections) + }) + + collectionsByTenant.forEach((collections, domain) => { + collections.sort((a, b) => b.ageHours - a.ageHours) + adhocOnly.delete(domain) + }) + + const scale = SCALE_TYPES.map(({ type, label }) => ({ + label, + value: totals.get(type) ?? 0, + average: tenantCount ? Math.round((totals.get(type) ?? 0) / tenantCount) : 0, + })) + + let fresh = 0 + let stale = 0 + let missing = 0 + const staleTenants = [] + + // A tenant the cache has never written has no rows at all, so absence is the signal here — + // iterate the tenant list rather than the returned rows. + tenants.forEach((tenant) => { + const domain = tenant?.defaultDomainName + const collections = (domain && collectionsByTenant.get(domain)) || [] + const name = tenant?.displayName || domain + const oldest = collections[0] + + if (!oldest) { + missing += 1 + staleTenants.push({ + name, + domain, + detail: adhocOnly.has(domain) + ? 'Only on-demand collections cached' + : 'No cached collections found', + severity: 'critical', + ageHours: null, + collections: [], + }) + return + } + + const age = oldest.ageHours + if (age <= STALE_HOURS) { + fresh += 1 + return + } + + stale += 1 + staleTenants.push({ + name, + domain, + detail: + age > CRITICAL_HOURS + ? `Oldest collection ${Math.round(age / 24)} days old` + : `Oldest collection ${Math.round(age)} hours old`, + severity: age > CRITICAL_HOURS ? 'critical' : 'warning', + ageHours: age, + // Only what is actually behind — a tenant that missed one run has three stale collections, + // not the eighty that refreshed fine. + collections: collections.filter((entry) => entry.ageHours > STALE_HOURS), + }) + }) + + // Worst first: never cached, then oldest. The list scrolls rather than truncating at five, so this + // order is the triage order. + staleTenants.sort((a, b) => { + if ((a.ageHours === null) !== (b.ageHours === null)) return a.ageHours === null ? -1 : 1 + return (b.ageHours ?? 0) - (a.ageHours ?? 0) + }) + + return { + scale, + hasData: rows.length > 0, + freshness: { fresh, stale, missing }, + staleTenants, + } +} + /** * Every read the All Tenants dashboard performs, plus the derivations each card needs. * @@ -308,74 +437,10 @@ export const useAllTenantsDashboard = () => { /* ------------------------------------------------------ scale and freshness */ - const cache = useMemo(() => { - const rows = asArray(countsApi.data) - - const totals = new Map() - const tenantOldest = new Map() - - rows.forEach((row) => { - const type = row?.Type - const count = Number(row?.Count ?? 0) - if (type) totals.set(type, (totals.get(type) ?? 0) + count) - - const age = hoursSince(row?.LastRefresh) - if (row?.Tenant && age !== null) { - const current = tenantOldest.get(row.Tenant) - if (current === undefined || age > current) tenantOldest.set(row.Tenant, age) - } - }) - - const scale = SCALE_TYPES.map(({ type, label }) => ({ - label, - value: totals.get(type) ?? 0, - average: tenantCount ? Math.round((totals.get(type) ?? 0) / tenantCount) : 0, - })) - - let fresh = 0 - let stale = 0 - let missing = 0 - const staleTenants = [] - - // A tenant the cache has never written has no rows at all, so absence is the signal here — - // iterate the tenant list rather than the returned rows. - tenants.forEach((tenant) => { - const domain = tenant?.defaultDomainName - const age = domain ? tenantOldest.get(domain) : undefined - const name = tenant?.displayName || domain - if (age === undefined) { - missing += 1 - staleTenants.push({ - name, - detail: 'No cached collections found', - severity: 'critical', - }) - } else if (age > 72) { - stale += 1 - staleTenants.push({ - name, - detail: `Oldest collection ${Math.round(age / 24)} days old`, - severity: 'critical', - }) - } else if (age > 30) { - stale += 1 - staleTenants.push({ - name, - detail: `Oldest collection ${Math.round(age)} hours old`, - severity: 'warning', - }) - } else { - fresh += 1 - } - }) - - return { - scale, - hasData: rows.length > 0, - freshness: { fresh, stale, missing }, - staleTenants: staleTenants.slice(0, 5), - } - }, [countsApi.data, tenants, tenantCount]) + const cache = useMemo( + () => deriveCacheSummary(asArray(countsApi.data), tenants), + [countsApi.data, tenants] + ) /* ------------------------------------------------------------ secure score */ diff --git a/src/components/CippCards/CippSharePointQuotaCard.jsx b/src/components/CippCards/CippSharePointQuotaCard.jsx new file mode 100644 index 000000000000..12df3dc44b0c --- /dev/null +++ b/src/components/CippCards/CippSharePointQuotaCard.jsx @@ -0,0 +1,122 @@ +import { Card, Chip, Skeleton, Stack, SvgIcon, Tooltip, Typography } from '@mui/material' +import { Box } from '@mui/system' +import { Storage } from '@mui/icons-material' +import { ApiGetCall } from '../../api/ApiCall' +import { useSettings } from '../../hooks/use-settings' +import { usePermissions } from '../../hooks/use-permissions' +import { LinearProgressWithLabel } from '../linearProgressWithLabel' + +// SharePoint reports the tenant quota in MB, and a tenant pool is routinely multiple TB. +// Roll the unit up so the figures stay readable instead of printing seven-digit megabytes. +const formatStorage = (sizeInMB) => { + const size = Number(sizeInMB) + if (!Number.isFinite(size)) return 'N/A' + if (size >= 1024 * 1024) return `${(size / 1024 / 1024).toFixed(2)} TB` + if (size >= 1024) return `${(size / 1024).toFixed(2)} GB` + return `${Math.round(size)} MB` +} + +/** + * Slim tenant-wide SharePoint storage bar, ported from the "SharePoint Quota" donut on the + * classic (v1) dashboard. Reads the SPO admin StorageQuotas API, so it is unaffected by the + * usage-report anonymization that can blank out the per-site columns in the table below. + * + * The totals are tenant-wide: on a Multi-Geo tenant the endpoint sums used storage across + * every geo location against the shared tenant pool. A per-geo chip row is added when there + * is more than one location, so the aggregate never hides where the storage actually sits. + * + * Renders nothing when the data can't apply: AllTenants (the endpoint answers "Not Supported") + * or a user without the Sharepoint.Admin read permission the endpoint requires. + */ +export const CippSharePointQuotaCard = () => { + const currentTenant = useSettings().currentTenant + const { checkPermissions } = usePermissions() + const canReadQuota = checkPermissions(['Sharepoint.Admin.Read', 'Sharepoint.Admin.ReadWrite']) + const isAllTenants = currentTenant === 'AllTenants' + const enabled = !!currentTenant && !isAllTenants && canReadQuota + + const quota = ApiGetCall({ + url: '/api/ListSharepointQuota', + data: { tenantFilter: currentTenant }, + // Same key the v1 dashboard uses, so the two share a single fetch. + queryKey: `${currentTenant}-ListSharepointQuota`, + waiting: enabled, + }) + + if (!enabled) return null + + const usedMB = Number(quota.data?.GeoUsedStorageMB) + const totalMB = Number(quota.data?.TenantStorageMB) + // The endpoint returns a 'Not available' percentage and no figures when the SharePoint admin + // link or the quota call fails, so gate on having real numbers rather than on the status. + const hasQuota = Number.isFinite(usedMB) && Number.isFinite(totalMB) && totalMB > 0 + const percentage = hasQuota ? Math.min(100, Math.round((usedMB / totalMB) * 1000) / 10) : 0 + // Only worth showing when the tenant actually spans geos - a single-geo tenant's one entry + // repeats the Used chip above it. + const geoLocations = Array.isArray(quota.data?.GeoLocations) ? quota.data.GeoLocations : [] + const isMultiGeo = geoLocations.length > 1 + + return ( + + + + + + + + Tenant Storage + + + {quota.isFetching ? ( + + ) : hasQuota ? ( + <> + + + + + + + + + + + + + + {isMultiGeo && + geoLocations.map((geo, index) => ( + + + + ))} + + + ) : ( + + Tenant storage usage is unavailable for this tenant. + + )} + + + ) +} + +export default CippSharePointQuotaCard diff --git a/src/components/CippComponents/CippAutocomplete.jsx b/src/components/CippComponents/CippAutocomplete.jsx index 5a1953382052..b8dbd37bb109 100644 --- a/src/components/CippComponents/CippAutocomplete.jsx +++ b/src/components/CippComponents/CippAutocomplete.jsx @@ -216,10 +216,11 @@ export const CippAutoComplete = React.forwardRef((props, ref) => { return result } - // Flatten the results from all pages + // Flatten the results from all pages. A dataKey can be present but null (e.g. an API + // returning {"Accounts":null}), which must read as "no options", not as a null option. const combinedResults = allPages.flatMap((page) => { const nestedData = getNestedValue(page, currentApi?.dataKey) - return nestedData !== undefined ? nestedData : [] + return nestedData ?? [] }) if (!Array.isArray(combinedResults)) { @@ -230,8 +231,11 @@ export const CippAutoComplete = React.forwardRef((props, ref) => { }, ]) } else { - // Convert each item into your { label, value, addedFields, rawData } shape - const convertedOptions = combinedResults.map((option) => { + // Convert each item into your { label, value, addedFields, rawData } shape. + // Null items would throw on the label/value field lookups below. + const convertedOptions = combinedResults + .filter((option) => option !== null && option !== undefined) + .map((option) => { const addedFields = {} if (currentApi?.addedField) { Object.keys(currentApi.addedField).forEach((key) => { diff --git a/src/components/CippComponents/CippTenantSelector.jsx b/src/components/CippComponents/CippTenantSelector.jsx index d98a00797f01..a9f120aa7c00 100644 --- a/src/components/CippComponents/CippTenantSelector.jsx +++ b/src/components/CippComponents/CippTenantSelector.jsx @@ -244,8 +244,11 @@ export const CippTenantSelector = React.forwardRef((props, ref) => { clearTimeout(routerUpdateTimeoutRef.current); } - // Cancel all in-flight queries before changing tenant - queryClient.cancelQueries(); + // Only cancel on a real tenant change; cancelling the initial-load URL backfill + // aborts mount fetches that react-query never retries. + if (query.tenantFilter && query.tenantFilter !== currentTenant.value) { + queryClient.cancelQueries(); + } // Update router only - let the URL watcher handle settings query.tenantFilter = currentTenant.value; diff --git a/src/components/CippTable/CIPPTableToptoolbar.js b/src/components/CippTable/CIPPTableToptoolbar.js index 595da5b7f2b5..6850bf66288a 100644 --- a/src/components/CippTable/CIPPTableToptoolbar.js +++ b/src/components/CippTable/CIPPTableToptoolbar.js @@ -207,7 +207,9 @@ export const CIPPTableToptoolbar = React.memo( const getBulkActions = (actions, selectedRows) => { return ( actions - ?.filter((action) => !action.link && !action?.hideBulk) + // customComponent actions are single-row dialogs; the bulk path renders CippApiDialog + // unconditionally, so admitting one here produces an empty dialog with no API behind it. + ?.filter((action) => !action.link && !action?.hideBulk && !action?.customComponent) ?.map((action) => ({ ...action, // bulkFilterEligible actions run against the eligible subset of the selection: diff --git a/src/components/CippWizard/CippAddTenantTypeSelection.jsx b/src/components/CippWizard/CippAddTenantTypeSelection.jsx index f42c0d6d632f..27b3bf210c3a 100644 --- a/src/components/CippWizard/CippAddTenantTypeSelection.jsx +++ b/src/components/CippWizard/CippAddTenantTypeSelection.jsx @@ -9,19 +9,16 @@ export const CippAddTenantTypeSelection = (props) => { const [selectedOption, setSelectedOption] = useState(null) - // Fetch host tenant organization to check partnerTenantType. - // No tenantFilter means the backend defaults to $env:TenantID (the CIPP host tenant). + // Ask the backend whether this CIPP instance runs on a partner tenant. Deliberately not a + // direct Graph call: the tenant-scoped route is denied for custom roles that block the + // partner tenant, which greys out the partner-only options below for roles that are + // otherwise fully permitted. ListPartnerTenantInfo pins the lookup to the host tenant. const organization = ApiGetCall({ - url: '/api/ListGraphRequest', - queryKey: 'ListGraphRequest-organization-partnerTenantType', - data: { - Endpoint: 'organization', - $select: 'partnerTenantType,displayName', - }, + url: '/api/ListPartnerTenantInfo', + queryKey: 'ListPartnerTenantInfo', }) - const partnerTenantType = organization.data?.Results?.[0]?.partnerTenantType - const isPartner = organization.isSuccess && Boolean(partnerTenantType) + const isPartner = organization.isSuccess && Boolean(organization.data?.isPartnerTenant) const partnerCheckComplete = organization.isSuccess || organization.isError // Register the tenantType field in react-hook-form diff --git a/src/components/ReleaseNotesDialog.js b/src/components/ReleaseNotesDialog.js index 1e7f3e166e1c..0ad27744fbea 100644 --- a/src/components/ReleaseNotesDialog.js +++ b/src/components/ReleaseNotesDialog.js @@ -83,8 +83,8 @@ const deleteCookie = (name) => { // 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). +// baseTag (vX.Y.0) is what the dialog selects by default so the feature-release notes lead; +// hotfix notes stay reachable via the dropdown. const buildReleaseMetadata = (version) => { const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(version ?? '')) const [major, minor, patch] = match ? match.slice(1) : ['0', '0', '0'] @@ -140,7 +140,7 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { const [open, setOpen] = useState(false) const [isExpanded, setIsExpanded] = useState(false) const [manualOpenRequested, setManualOpenRequested] = useState(false) - const [selectedReleaseTag, setSelectedReleaseTag] = useState(releaseMeta.releaseTag) + const [selectedReleaseTag, setSelectedReleaseTag] = useState(releaseMeta.baseTag) const hasOpenedRef = useRef(false) useEffect(() => { @@ -148,8 +148,8 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { }, [releaseMeta.releaseTag]) useEffect(() => { - setSelectedReleaseTag(releaseMeta.releaseTag) - }, [releaseMeta.releaseTag]) + setSelectedReleaseTag(releaseMeta.baseTag) + }, [releaseMeta.baseTag]) useEffect(() => { if (typeof window === 'undefined') { diff --git a/src/data/standards.json b/src/data/standards.json index 62a8932f623d..ac06341b8b8c 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -6449,8 +6449,10 @@ "label": "Policy Assignment", "options": [ { "label": "Do not assign", "value": "none" }, - { "label": "All devices", "value": "AllDevices" }, - { "label": "All users and devices", "value": "AllDevicesAndUsers" } + { + "label": "All users (Device Preparation profiles deploy to the enrolling user, so device targets do not apply)", + "value": "AllDevicesAndUsers" + } ] } ], diff --git a/src/pages/cipp/settings/permissions.js b/src/pages/cipp/settings/permissions.js index 4393a3e602e7..4b3f60f91633 100644 --- a/src/pages/cipp/settings/permissions.js +++ b/src/pages/cipp/settings/permissions.js @@ -11,20 +11,15 @@ import { ApiGetCall } from "../../../api/ApiCall"; const Page = () => { const [importReport, setImportReport] = useState(false); - // Same signal the Add Tenant wizard uses to decide whether partner-only flows apply. - // No tenantFilter means the backend defaults to the CIPP host tenant. + // Same signal the Add Tenant wizard uses to decide whether partner-only flows apply, and the + // same shared query key so the two pages hit one cache entry. const organization = ApiGetCall({ - url: "/api/ListGraphRequest", - queryKey: "ListGraphRequest-organization-partnerTenantType", - data: { - Endpoint: "organization", - $select: "partnerTenantType,displayName", - }, + url: "/api/ListPartnerTenantInfo", + queryKey: "ListPartnerTenantInfo", }); - const partnerTenantType = organization.data?.Results?.[0]?.partnerTenantType; const partnerCheckComplete = organization.isSuccess || organization.isError; - const isPartner = organization.isSuccess && Boolean(partnerTenantType); + const isPartner = organization.isSuccess && Boolean(organization.data?.isPartnerTenant); // Keep the GDAP check visible until we know it does not apply, and always show it when an // imported report contains GDAP data. diff --git a/src/pages/email/resources/management/equipment/index.js b/src/pages/email/resources/management/equipment/index.js index 4a1b45a30413..5f5e4c40ab58 100644 --- a/src/pages/email/resources/management/equipment/index.js +++ b/src/pages/email/resources/management/equipment/index.js @@ -14,7 +14,9 @@ const Page = () => { link: `/email/resources/management/equipment/edit?equipmentId=[ExternalDirectoryObjectId]`, icon: , color: "info", - condition: (row) => !row.isDirSynced, + // ListEquipment returns the raw Get-Mailbox object, so these are PascalCase like the + // columns below - reading row.isDirSynced here is always undefined and never gates. + condition: (row) => !row.IsDirSynced, }, { label: "Edit permissions", @@ -30,7 +32,7 @@ const Page = () => { data: { ID: "ExternalDirectoryObjectId" }, confirmText: "Are you sure you want to block the sign-in for this equipment mailbox?", multiPost: false, - condition: (row) => !row.isDirSynced, + condition: (row) => !row.AccountDisabled && !row.IsDirSynced, }, { label: "Unblock Sign In", @@ -40,7 +42,7 @@ const Page = () => { data: { ID: "ExternalDirectoryObjectId", Enable: true }, confirmText: "Are you sure you want to unblock sign-in for this equipment mailbox?", multiPost: false, - condition: (row) => !row.isDirSynced, + condition: (row) => row.AccountDisabled && !row.IsDirSynced, }, { label: "Delete Equipment", @@ -50,7 +52,7 @@ const Page = () => { data: { ID: "ExternalDirectoryObjectId" }, confirmText: "Are you sure you want to delete this equipment mailbox?", multiPost: false, - condition: (row) => !row.isDirSynced, + condition: (row) => !row.IsDirSynced, }, ]; diff --git a/src/pages/security/reports/cve-report/index.js b/src/pages/security/reports/cve-report/index.js index b0d8317374d8..1c721955990e 100644 --- a/src/pages/security/reports/cve-report/index.js +++ b/src/pages/security/reports/cve-report/index.js @@ -18,7 +18,7 @@ const Page = () => { "exceptionType", "exceptionComment", "exceptionCreatedBy", - "exceptionReadableDate", + "exceptionDate", "exceptionExpiry", ]} /> diff --git a/src/pages/security/safelinks/safelinks/index.jsx b/src/pages/security/safelinks/safelinks/index.jsx index 02ccc9f872ea..bbc08e2a0b75 100644 --- a/src/pages/security/safelinks/safelinks/index.jsx +++ b/src/pages/security/safelinks/safelinks/index.jsx @@ -21,6 +21,19 @@ const Page = () => { } ]; + // Rows for orphaned built-in EOP rules carry PolicyName = null, so every condition has to + // tolerate a missing name rather than dereferencing it. A row with no policy behind it is + // Microsoft managed for these purposes, which is what the string comparisons already encode. + const isMicrosoftManaged = (row) => { + const name = row?.PolicyName ?? ""; + return ( + row?.IsBuiltIn === true || + name.startsWith("Standard Preset Security Policy") || + name.startsWith("Strict Preset Security Policy") || + name === "Built-In Protection Policy" + ); + }; + const actions = [ { label: "Edit Safe Links Policy", @@ -28,7 +41,7 @@ const Page = () => { icon: , color: "success", target: "_self", - condition: (row) => !row.IsBuiltInProtection && !row.PolicyName.startsWith("Standard Preset Security Policy") && !row.PolicyName.startsWith("Strict Preset Security Policy") && row.PolicyName !== "Built-In Protection Policy", + condition: (row) => !isMicrosoftManaged(row), }, { label: "Enable Rule", @@ -42,7 +55,7 @@ const Page = () => { }, confirmText: "Are you sure you want to enable this rule?", color: "info", - condition: (row) => row.State === "Disabled" && !row.IsBuiltInProtection && !row.PolicyName.startsWith("Standard Preset Security Policy") && !row.PolicyName.startsWith("Strict Preset Security Policy")&& row.PolicyName !== "Built-In Protection Policy", + condition: (row) => row.State === "Disabled" && !isMicrosoftManaged(row), }, { label: "Disable Rule", @@ -56,14 +69,14 @@ const Page = () => { }, confirmText: "Are you sure you want to disable this rule?", color: "info", - condition: (row) => row.State === "Enabled" && !row.IsBuiltInProtection && !row.PolicyName.startsWith("Standard Preset Security Policy") && !row.PolicyName.startsWith("Strict Preset Security Policy")&& row.PolicyName !== "Built-In Protection Policy", + condition: (row) => row.State === "Enabled" && !isMicrosoftManaged(row), }, { label: "Set Priority", type: "POST", icon: , url: "/api/EditSafeLinksPolicy", - condition: (row) => !row.IsBuiltInProtection && !row.PolicyName.startsWith("Standard Preset Security Policy") && !row.PolicyName.startsWith("Strict Preset Security Policy")&& row.PolicyName !== "Built-In Protection Policy", + condition: (row) => !isMicrosoftManaged(row), data: { PolicyName: "PolicyName", Name: "PolicyName" @@ -95,7 +108,7 @@ const Page = () => { confirmText: "Are you sure you want to create a template based on this policy?", icon: , hideBulk: true, - condition: (row) => !row.IsBuiltInProtection && !row.PolicyName.startsWith("Standard Preset Security Policy") && !row.PolicyName.startsWith("Strict Preset Security Policy")&& row.PolicyName !== "Built-In Protection Policy", + condition: (row) => !isMicrosoftManaged(row), }, { label: "Delete Rule", @@ -108,7 +121,7 @@ const Page = () => { }, confirmText: "Are you sure you want to delete this policy and rule?", color: "danger", - condition: (row) => !row.IsBuiltInProtection && !row.PolicyName.startsWith("Standard Preset Security Policy") && !row.PolicyName.startsWith("Strict Preset Security Policy")&& row.PolicyName !== "Built-In Protection Policy", + condition: (row) => !isMicrosoftManaged(row), } ]; diff --git a/src/pages/teams-share/external-users.js b/src/pages/teams-share/external-users.js index 85fd7a8fa31c..b14abaae836e 100644 --- a/src/pages/teams-share/external-users.js +++ b/src/pages/teams-share/external-users.js @@ -22,14 +22,16 @@ const Page = () => { icon: , url: '/api/ExecRemoveSPOExternalUser', customDataformatter: (row) => { - const r = Array.isArray(row) ? row[0] : row - return { + const formatRow = (r) => ({ tenantFilter: r.Tenant ?? tenantFilter, EntraUserId: r.EntraUserId, LoginName: r.LoginName, SiteUrls: Array.isArray(r.Sites) ? r.Sites : [], DisplayName: r.DisplayName, - } + }) + // When multiple rows are selected, row is an array. Returning an array + // makes CippApiDialog send one request per row (bulk request mode). + return Array.isArray(row) ? row.map(formatRow) : formatRow(row) }, confirmText: 'Fully remove guest access for [DisplayName]? This deletes their Entra guest account (if one exists) AND removes them from every site listed in the Sites column, so nothing is left orphaned. Sharing links they hold can be revoked from the Sharing Report; the inert SharePoint store entry ages out on its own.', diff --git a/src/pages/teams-share/sharepoint/index.js b/src/pages/teams-share/sharepoint/index.js index f0c85a6ccffa..d5cd31bed46f 100644 --- a/src/pages/teams-share/sharepoint/index.js +++ b/src/pages/teams-share/sharepoint/index.js @@ -31,6 +31,12 @@ import { CippEditSitePropertiesForm } from '../../../components/CippComponents/C import { CippSiteRecycleBinDialog } from '../../../components/CippComponents/CippSiteRecycleBinDialog' import { CippLibraryPermissionsDialog } from '../../../components/CippComponents/CippLibraryPermissionsDialog' import { CippCheckUserAccessDialog } from '../../../components/CippComponents/CippCheckUserAccessDialog' +import { CippSharePointQuotaCard } from '../../../components/CippCards/CippSharePointQuotaCard' +import { + CippAnonymizedReportAlert, + isReportAnonymized, + useReportAnonymized, +} from '../../../components/CippComponents/CippAnonymizedReportAlert' // Friendly labels for the SharePoint version cleanup (trim) job progress fields. const VERSION_CLEANUP_LABELS = { @@ -150,6 +156,30 @@ const Page = () => { allowAllTenantSync: true, }) + // Two different faults produce empty usage columns here, and they need different advice. + // + // Anonymization: Microsoft 365 hashes the owner names in the SharePoint site usage report. + // Only hashed values prove this - absent usage data does not, because anonymization still + // returns rows, it just hashes them. Both the live and cached paths merge the same report, + // so this is not gated on cache mode. + const anonymizedReport = useReportAnonymized({ + url: reportDB.resolvedApiUrl, + data: reportDB.resolvedApiData, + queryKey: reportDB.resolvedQueryKey, + check: (rows) => isReportAnonymized(rows, ['ownerPrincipalName', 'ownerDisplayName']), + }) + + // Empty usage report: getSharePointSiteUsageDetail returns no rows at all for tenants + // Microsoft has not generated a report for yet. The site listing still populates the table, + // so every usage-derived column is blank. reportRefreshDate comes only from that report, so + // an empty one across every row means the merge contributed nothing. + const noUsageData = useReportAnonymized({ + url: reportDB.resolvedApiUrl, + data: reportDB.resolvedApiData, + queryKey: reportDB.resolvedQueryKey, + check: (rows) => rows.every((site) => !site?.reportRefreshDate), + }) + const actions = [ { label: 'Add Member', @@ -357,48 +387,54 @@ const Page = () => { ), customDataformatter: (row, action, formData) => { - const siteRow = Array.isArray(row) ? row[0] : row - const isGroupSite = siteRow?.rootWebTemplate === 'Group' const v = (x) => (x && typeof x === 'object' && 'value' in x ? x.value : x) - const payload = { - tenantFilter: siteRow.Tenant ?? tenantFilter, - SiteUrl: siteRow.webUrl, - SharingCapability: v(formData.SharingCapability), - DefaultSharingLinkType: v(formData.DefaultSharingLinkType), - DefaultLinkPermission: v(formData.DefaultLinkPermission), - LockState: v(formData.LockState), - } - if (!isGroupSite) { - payload.Title = formData.Title - payload.SharingDomainRestrictionMode = v(formData.SharingDomainRestrictionMode) - payload.OverrideTenantAnonymousLinkExpirationPolicy = - !!formData.OverrideTenantAnonymousLinkExpirationPolicy - payload.InheritVersionPolicyFromTenant = !!formData.InheritVersionPolicyFromTenant - } - if (!isGroupSite && v(formData.SharingDomainRestrictionMode) === 'AllowList') { - payload.SharingAllowedDomainList = formData.SharingAllowedDomainList - } - if (!isGroupSite && v(formData.SharingDomainRestrictionMode) === 'BlockList') { - payload.SharingBlockedDomainList = formData.SharingBlockedDomainList - } - if (!isGroupSite && formData.OverrideTenantAnonymousLinkExpirationPolicy) { - payload.AnonymousLinkExpirationInDays = parseInt( - formData.AnonymousLinkExpirationInDays ?? 0, - 10 - ) - } - const storageMax = parseInt(formData.StorageMaximumLevel, 10) - const storageWarn = parseInt(formData.StorageWarningLevel, 10) - if (!isNaN(storageMax) && storageMax > 0) payload.StorageMaximumLevel = storageMax - if (!isNaN(storageWarn) && storageWarn > 0) payload.StorageWarningLevel = storageWarn - if (!isGroupSite && !formData.InheritVersionPolicyFromTenant) { - payload.EnableAutoExpirationVersionTrim = !!formData.EnableAutoExpirationVersionTrim - if (!formData.EnableAutoExpirationVersionTrim) { - payload.MajorVersionLimit = parseInt(formData.MajorVersionLimit ?? 0, 10) - payload.ExpireVersionsAfterDays = parseInt(formData.ExpireVersionsAfterDays ?? 0, 10) + // isGroupSite is evaluated per site: a selection can mix group-backed and classic + // sites, and the group-backed ones reject the properties guarded below. + const formatRow = (siteRow) => { + const isGroupSite = siteRow?.rootWebTemplate === 'Group' + const payload = { + tenantFilter: siteRow.Tenant ?? tenantFilter, + SiteUrl: siteRow.webUrl, + SharingCapability: v(formData.SharingCapability), + DefaultSharingLinkType: v(formData.DefaultSharingLinkType), + DefaultLinkPermission: v(formData.DefaultLinkPermission), + LockState: v(formData.LockState), + } + if (!isGroupSite) { + payload.Title = formData.Title + payload.SharingDomainRestrictionMode = v(formData.SharingDomainRestrictionMode) + payload.OverrideTenantAnonymousLinkExpirationPolicy = + !!formData.OverrideTenantAnonymousLinkExpirationPolicy + payload.InheritVersionPolicyFromTenant = !!formData.InheritVersionPolicyFromTenant } + if (!isGroupSite && v(formData.SharingDomainRestrictionMode) === 'AllowList') { + payload.SharingAllowedDomainList = formData.SharingAllowedDomainList + } + if (!isGroupSite && v(formData.SharingDomainRestrictionMode) === 'BlockList') { + payload.SharingBlockedDomainList = formData.SharingBlockedDomainList + } + if (!isGroupSite && formData.OverrideTenantAnonymousLinkExpirationPolicy) { + payload.AnonymousLinkExpirationInDays = parseInt( + formData.AnonymousLinkExpirationInDays ?? 0, + 10 + ) + } + const storageMax = parseInt(formData.StorageMaximumLevel, 10) + const storageWarn = parseInt(formData.StorageWarningLevel, 10) + if (!isNaN(storageMax) && storageMax > 0) payload.StorageMaximumLevel = storageMax + if (!isNaN(storageWarn) && storageWarn > 0) payload.StorageWarningLevel = storageWarn + if (!isGroupSite && !formData.InheritVersionPolicyFromTenant) { + payload.EnableAutoExpirationVersionTrim = !!formData.EnableAutoExpirationVersionTrim + if (!formData.EnableAutoExpirationVersionTrim) { + payload.MajorVersionLimit = parseInt(formData.MajorVersionLimit ?? 0, 10) + payload.ExpireVersionsAfterDays = parseInt(formData.ExpireVersionsAfterDays ?? 0, 10) + } + } + return payload } - return payload + // When multiple rows are selected, row is an array. Returning an array + // makes CippApiDialog send one request per row (bulk request mode). + return Array.isArray(row) ? row.map(formatRow) : formatRow(row) }, multiPost: false, allowResubmit: true, @@ -514,6 +550,7 @@ const Page = () => { /> ), multiPost: false, + hideBulk: true, }, { label: 'Delete Site', @@ -647,6 +684,7 @@ const Page = () => { /> ), multiPost: false, + hideBulk: true, }, { label: 'Check Cleanup Job Status', @@ -661,6 +699,7 @@ const Page = () => { /> ), multiPost: false, + hideBulk: true, }, ] @@ -727,6 +766,22 @@ const Page = () => { offCanvas={offCanvas} simpleColumns={simpleColumns} cardButton={pageActions} + tableFilter={ + <> + + + Site owner names in this report are pseudo-anonymised because Microsoft 365 report + anonymization is enabled for this tenant. + + {!anonymizedReport && noUsageData && ( + + Microsoft returned no SharePoint usage report for this tenant, so activity, + storage and file count are blank. The site list itself is complete. Usage reports + can take up to 48 hours to appear on a new tenant. + + )} + + } /> {reportDB.syncDialog} diff --git a/tests/components/CippAllTenants/AllTenantsCacheList.test.jsx b/tests/components/CippAllTenants/AllTenantsCacheList.test.jsx new file mode 100644 index 000000000000..49f8e2b077c0 --- /dev/null +++ b/tests/components/CippAllTenants/AllTenantsCacheList.test.jsx @@ -0,0 +1,83 @@ +import React from 'react' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithTheme } from '../../test-utils' +import { AllTenantsCacheList } from '../../../src/components/CippAllTenants/AllTenantsPrimitives' + +const staleRow = { + name: 'Contoso', + domain: 'contoso.onmicrosoft.com', + detail: 'Oldest collection 9 days old', + severity: 'critical', + ageHours: 216, + collections: [ + { + type: 'SPOTenant', + lastRefresh: '2026-08-04T19:07:27.869Z', + ageHours: 216, + }, + { + type: 'SiteActivity', + lastRefresh: '2026-08-10T11:15:01.329Z', + ageHours: 60, + }, + ], +} + +const neverCachedRow = { + name: 'Fabrikam', + domain: 'fabrikam.onmicrosoft.com', + detail: 'No cached collections found', + severity: 'critical', + ageHours: null, + collections: [], +} + +describe('AllTenantsCacheList', () => { + it('renders the empty text when nothing is behind', () => { + renderWithTheme() + expect(screen.getByText('All fresh')).toBeInTheDocument() + }) + + it('keeps the collection detail hidden until the row is expanded', async () => { + renderWithTheme() + + expect(screen.getByText('Contoso')).toBeInTheDocument() + expect(screen.getByText('2 stale')).toBeInTheDocument() + expect(screen.queryByText('SPOTenant')).not.toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { expanded: false })) + + expect(screen.getByText('SPOTenant')).toBeInTheDocument() + expect(screen.getByText('SiteActivity')).toBeInTheDocument() + }) + + it('shows each collection with its own last refresh time', async () => { + renderWithTheme() + await userEvent.click(screen.getByRole('button', { expanded: false })) + + // The absolute stamp is locale-formatted, so assert on the age suffix the row appends to it. + expect(screen.getByText(/9 days ago/)).toBeInTheDocument() + expect(screen.getByText(/60 hours ago/)).toBeInTheDocument() + }) + + it('does not offer an expander for a tenant with nothing cached', () => { + renderWithTheme() + + expect(screen.getByText('Fabrikam')).toBeInTheDocument() + expect(screen.getByText('No cached collections found')).toBeInTheDocument() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('renders every stale tenant rather than the first few', () => { + const rows = Array.from({ length: 9 }, (_, index) => ({ + ...staleRow, + name: `Tenant ${index}`, + domain: `tenant${index}.onmicrosoft.com`, + })) + renderWithTheme() + + expect(screen.getByText('Tenant 0')).toBeInTheDocument() + expect(screen.getByText('Tenant 8')).toBeInTheDocument() + }) +}) diff --git a/tests/components/CippAllTenants/useAllTenantsDashboard.test.js b/tests/components/CippAllTenants/useAllTenantsDashboard.test.js new file mode 100644 index 000000000000..6655f0222c14 --- /dev/null +++ b/tests/components/CippAllTenants/useAllTenantsDashboard.test.js @@ -0,0 +1,159 @@ +import { deriveCacheSummary } from '../../../src/components/CippAllTenants/useAllTenantsDashboard' + +const HOUR = 3600000 + +const hoursAgo = (hours) => new Date(Date.now() - hours * HOUR).toISOString() + +const tenant = (domain, displayName = domain) => ({ + defaultDomainName: domain, + displayName, +}) + +const row = (Tenant, Type, hours, Count = 1) => ({ + Tenant, + Type, + Count, + LastRefresh: hoursAgo(hours), +}) + +describe('deriveCacheSummary', () => { + it('ages a tenant by its oldest scheduled collection', () => { + const summary = deriveCacheSummary( + [row('a.com', 'Users', 2), row('a.com', 'Mailboxes', 100)], + [tenant('a.com', 'Alpha')] + ) + + expect(summary.freshness).toEqual({ fresh: 0, stale: 1, missing: 0 }) + expect(summary.staleTenants[0]).toMatchObject({ + name: 'Alpha', + domain: 'a.com', + detail: 'Oldest collection 4 days old', + severity: 'critical', + }) + }) + + it('ignores collections the nightly orchestrator never refreshes', () => { + // SharePointSharingLinks is populated on demand only, so a months-old row says nothing about + // whether this tenant is still syncing. + const summary = deriveCacheSummary( + [ + row('a.com', 'Users', 2), + row('a.com', 'SharePointSharingLinks', 24 * 90), + row('a.com', 'SharePointPermissions', 24 * 60), + row('a.com', 'OneDriveRootPermissions', 24 * 45), + ], + [tenant('a.com', 'Alpha')] + ) + + expect(summary.freshness).toEqual({ fresh: 1, stale: 0, missing: 0 }) + expect(summary.staleTenants).toEqual([]) + }) + + it('says so when a tenant has only ad-hoc collections rather than none', () => { + const summary = deriveCacheSummary( + [row('a.com', 'SharePointSharingLinks', 24 * 90)], + [tenant('a.com', 'Alpha')] + ) + + expect(summary.freshness).toEqual({ fresh: 0, stale: 0, missing: 1 }) + expect(summary.staleTenants[0]).toMatchObject({ + name: 'Alpha', + detail: 'Only on-demand collections cached', + severity: 'critical', + ageHours: null, + collections: [], + }) + }) + + it('warns between 30 and 72 hours and reports the age in hours', () => { + const summary = deriveCacheSummary( + [row('a.com', 'Users', 48)], + [tenant('a.com', 'Alpha')] + ) + + expect(summary.freshness).toEqual({ fresh: 0, stale: 1, missing: 0 }) + expect(summary.staleTenants[0]).toMatchObject({ + detail: 'Oldest collection 48 hours old', + severity: 'warning', + }) + }) + + it('attaches only the collections that are behind, oldest first', () => { + const summary = deriveCacheSummary( + [ + row('a.com', 'Users', 2), + row('a.com', 'SPOTenant', 216), + row('a.com', 'Mailboxes', 72.5), + row('a.com', 'Groups', 29), + ], + [tenant('a.com', 'Alpha')] + ) + + const [alpha] = summary.staleTenants + expect(alpha.collections.map((entry) => entry.type)).toEqual([ + 'SPOTenant', + 'Mailboxes', + ]) + expect(alpha.collections[0].ageHours).toBeCloseTo(216, 1) + expect(alpha.collections[0].lastRefresh).toBeTruthy() + }) + + it('sorts never cached first, then oldest, without truncating the list', () => { + const tenants = ['a', 'b', 'c', 'd', 'e', 'f'].map((letter) => + tenant(`${letter}.com`) + ) + const summary = deriveCacheSummary( + [ + row('a.com', 'Users', 100), + row('b.com', 'Users', 400), + row('c.com', 'Users', 200), + row('d.com', 'Users', 50), + row('e.com', 'Users', 300), + ], + tenants + ) + + // f.com has no rows at all, so it leads; the rest follow oldest first. + expect(summary.staleTenants.map((entry) => entry.domain)).toEqual([ + 'f.com', + 'b.com', + 'e.com', + 'c.com', + 'a.com', + 'd.com', + ]) + }) + + it('still totals ad-hoc collections into the scale figures', () => { + // Excluding a type from the age judgement must not remove its records from the estate inventory. + const summary = deriveCacheSummary( + [ + row('a.com', 'Users', 2, 40), + row('b.com', 'Users', 2, 60), + row('a.com', 'SharePointSharingLinks', 24 * 90, 500), + ], + [tenant('a.com'), tenant('b.com')] + ) + + expect(summary.scale).toEqual([ + { label: 'Users', value: 100, average: 50 }, + { label: 'Mailboxes', value: 0, average: 0 }, + { label: 'Managed devices', value: 0, average: 0 }, + ]) + expect(summary.hasData).toBe(true) + }) + + it('reports tenants with no rows at all as never cached', () => { + const summary = deriveCacheSummary( + [row('a.com', 'Users', 2)], + [tenant('a.com'), tenant('b.com')] + ) + + expect(summary.freshness).toEqual({ fresh: 1, stale: 0, missing: 1 }) + expect(summary.staleTenants[0]).toMatchObject({ + name: 'b.com', + detail: 'No cached collections found', + severity: 'critical', + }) + }) +}) diff --git a/tests/components/CippComponents/CippBreadcrumbNav.test.jsx b/tests/components/CippComponents/CippBreadcrumbNav.test.jsx new file mode 100644 index 000000000000..06c181b55374 --- /dev/null +++ b/tests/components/CippComponents/CippBreadcrumbNav.test.jsx @@ -0,0 +1,27 @@ +import { screen } from '@testing-library/react' +import { renderWithProviders } from '../../test-utils' +import { CippBreadcrumbNav } from '../../../src/components/CippComponents/CippBreadcrumbNav' + +// second require.context consumer, this one globs every pages/**/tabOptions.json. covers the +// subdirectory + regex arms of the polyfill that the tutorial glob (flat, no subdirs) doesn't. +// 'Groups' only reaches the trail through src/pages/tenant/administration/tenants/tabOptions.json +vi.mock('next/router', () => ({ + useRouter: () => ({ + pathname: '/tenant/administration/tenants/groups', + asPath: '/tenant/administration/tenants/groups', + query: {}, + isReady: true, + push: () => Promise.resolve(), + replace: () => Promise.resolve(), + events: { on: () => {}, off: () => {}, emit: () => {} }, + }), +})) + +describe('CippBreadcrumbNav', () => { + it('labels the tab crumb from the tabOptions require.context', () => { + renderWithProviders() + + expect(screen.getByLabelText('page hierarchy')).toBeInTheDocument() + expect(screen.getByText('Groups')).toBeInTheDocument() + }) +}) diff --git a/tests/components/ReleaseNotesDialog.test.jsx b/tests/components/ReleaseNotesDialog.test.jsx index ba53c5cb6c13..e74a1c767f51 100644 --- a/tests/components/ReleaseNotesDialog.test.jsx +++ b/tests/components/ReleaseNotesDialog.test.jsx @@ -55,18 +55,18 @@ beforeEach(() => { }) describe('ReleaseNotesDialog', () => { - it('opens on the running hotfix release rather than its .0 base release', async () => { + it('opens on the .0 base release even when running a hotfix build', 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() + expect(await screen.findByText('Release notes for v10.8.0 - Ramos Melon Fizz')).toBeInTheDocument() + expect(screen.getByText('Notes for the base release of the 10.8 series')).toBeInTheDocument() }) 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 screen.findByText('Release notes for v10.8.0 - Ramos Melon Fizz') await user.click(screen.getByRole('button', { name: "Don't show until next release" })) await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) diff --git a/tests/contexts/tutorial-context.test.jsx b/tests/contexts/tutorial-context.test.jsx new file mode 100644 index 000000000000..5411488ab20d --- /dev/null +++ b/tests/contexts/tutorial-context.test.jsx @@ -0,0 +1,41 @@ +import { render, screen } from '@testing-library/react' +import { TutorialProvider, useTutorials } from '../../src/contexts/tutorial-context' + +// TutorialProvider loads its tours through webpack's require.context, which vite has no +// equivalent for. tests/mocks/require-context.js maps it onto import.meta.glob, so this +// render is what proves the polyfill actually reaches a src module. +const TutorialProbe = () => { + const { tutorials, getTutorialsForPage } = useTutorials() + return ( + <> +
    {tutorials.map((t) => t.id).join(',')}
    +
    {getTutorialsForPage('/').map((t) => t.id).join(',')}
    + + ) +} + +describe('TutorialProvider', () => { + it('loads the tutorial json off require.context', () => { + render( + + + + ) + + const ids = screen.getByTestId('ids').textContent.split(',') + expect(ids).toEqual( + expect.arrayContaining(['getting-started', 'dashboard-overview', 'tenant-management']) + ) + }) + + it('scopes tutorials to the page they declare', () => { + render( + + + + ) + + // getting-started declares pages: ['/'], the other two declare other routes + expect(screen.getByTestId('home').textContent).toBe('getting-started') + }) +}) diff --git a/vitest.config.mjs b/vitest.config.mjs index 8f2672cbc123..a48c9816f9f4 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -17,6 +17,23 @@ const nextAliases = { 'next/link': path.resolve(dirname, 'tests/mocks/next-link.js'), } +// vitest gives every module its own cjs `require`, which shadows the globalThis polyfill in +// tests/mocks/require-context.js. jsdom only - the browser project has no local require +const requireContextPlugin = { + name: 'cipp-require-context', + enforce: 'pre', + transform(code, id) { + if (id.includes('/node_modules/') || !code.includes('require.context(')) { + return null + } + // lookbehind so an already-prefixed call isn't rewritten to globalThis.globalThis.require + return { + code: code.replace(/(? Date: Fri, 14 Aug 2026 17:57:06 -0400 Subject: [PATCH 06/10] Downgrade Node.js and jsdom versions Downgrade Node.js and jsdom versions in package.json Signed-off-by: John Duprey --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 4d5b349be97a..ff1da83ceb4b 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ }, "license": "AGPL-3.0", "engines": { - "node": "^22.22.2" + "node": "^22.22.0" }, "repository": { "type": "git", @@ -135,7 +135,7 @@ "eslint": "^9.39.4", "eslint-config-next": "^16.2.10", "eslint-config-prettier": "^10.1.8", - "jsdom": "30.0.1", + "jsdom": "29.0.1", "msw": "2.12.14", "msw-storybook-addon": "3.0.0", "playwright": "1.59.1", @@ -153,4 +153,4 @@ "resolutions": { "vite": "7.3.6" } -} \ No newline at end of file +} From c79e59f622eb96f8babd55148d60e591c31e10ad Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 21 Aug 2026 12:20:55 +0000 Subject: [PATCH 07/10] Merge pull request #352 from CyberDrain/dev Dev to release Synced from CyberDrain/CIPP@f4ed822df189fd0acf2d6f0a1b255af014318014 --- package.json | 12 +- public/version.json | 4 +- src/api/ApiCall.jsx | 7 +- src/components/BECRemediationReportButton.js | 12 +- .../CippAllTenants/AllTenantsPrimitives.jsx | 2 +- .../CippBaselineStandardDialog.jsx | 768 ++++++-- .../CippBaselineStandardItem.jsx | 25 +- .../CippBaselineStandardSettings.jsx | 15 +- .../CippBaselineWhatIfReport.jsx | 10 +- .../CippCards/CippBannerListCard.jsx | 24 +- src/components/CippCards/CippChartCard.jsx | 37 +- src/components/CippCards/CippDomainCards.jsx | 6 +- src/components/CippCards/CippImageCard.jsx | 15 +- src/components/CippCards/CippPageCard.jsx | 20 +- .../CippCards/CippPropertyListCard.jsx | 2 +- .../CippCards/CippStandardsDialog.jsx | 8 +- .../CippCards/CippUniversalSearchV2.jsx | 254 ++- src/components/CippCards/CippUserInfoCard.jsx | 10 +- .../CippComponents/AlertsOverviewCard.jsx | 2 +- .../CippComponents/AuthMethodCard.jsx | 2 +- .../CippAddTestReportDrawer.jsx | 68 +- .../CippComponents/CippAddUserDrawer.jsx | 7 + .../CippComponents/CippApiDialog.jsx | 131 +- .../CippAppPermissionBuilder.jsx | 50 +- .../CippAppRegistrationSwitcher.jsx | 29 + .../CippComponents/CippAppTemplateDrawer.jsx | 8 +- .../CippApplicationDeployDrawer.jsx | 8 +- .../CippComponents/CippAutocomplete.jsx | 28 +- .../CippAutopilotProfileDrawer.jsx | 188 +- .../CippComponents/CippBottomSheet.jsx | 83 + .../CippComponents/CippBreadcrumbNav.jsx | 64 +- .../CippComponents/CippDateRangeFilter.jsx | 8 +- .../CippEnterpriseAppSwitcher.jsx | 28 + .../CippComponents/CippEntitySwitcher.jsx | 221 +++ .../CippComponents/CippExpandableAlert.jsx | 60 + .../CippGdapRelationshipSwitcher.jsx | 22 + .../CippImpersonationBanner.jsx | 103 ++ .../CippIntuneDeviceActions.jsx | 46 +- .../CippIntuneSettingsEditor.jsx | 4 +- .../CippMailboxRestoreDrawer.jsx | 4 +- .../CippComponents/CippMaintenanceBanner.jsx | 69 +- src/components/CippComponents/CippMap.jsx | 4 +- .../CippMessageDeliveryInfo.jsx | 2 +- .../CippComponents/CippMessageViewer.jsx | 12 +- .../CippComponents/CippMobileTenantPicker.jsx | 282 +++ .../CippComponents/CippOffCanvas.jsx | 152 +- .../CippOffboardingDefaultSettings.jsx | 16 +- .../CippComponents/CippPageActionsFab.jsx | 161 ++ .../CippPermissionSetDrawer.jsx | 4 +- .../CippComponents/CippPropertyList.jsx | 15 +- .../CippComponents/CippQuarantineDetails.jsx | 424 +++++ .../CippComponents/CippQuarantineTable.jsx | 555 ++++++ .../CippComponents/CippReportToolbar.jsx | 394 +++- .../CippComponents/CippRestoreWizard.jsx | 2 +- src/components/CippComponents/CippSankey.jsx | 180 +- .../CippComponents/CippSettingsSideBar.jsx | 2 + .../CippSharePointBrowserBanner.jsx | 123 ++ .../CippSharePointBrowserPermissions.jsx | 1637 +++++++++++++++++ .../CippSharePointBrowserProperties.jsx | 184 ++ .../CippSharePointBrowserStorage.jsx | 747 ++++++++ .../CippSharePointFolderView.jsx | 918 +++++++++ .../CippSharePointPermissionEditor.jsx | 4 +- .../CippComponents/CippSpeedDial.jsx | 8 + src/components/CippComponents/CippSponsor.jsx | 19 +- .../CippSupportBundleDialog.jsx | 345 ++++ .../CippTabNavigationSection.jsx | 57 + .../CippComponents/CippTabPicker.jsx | 100 + .../CippComponents/CippTableDialog.jsx | 68 +- .../CippComponents/CippTablePage.jsx | 77 +- .../CippComponents/CippTemplateCatalog.jsx | 2 +- .../CippTenantGroupRuleBuilder.jsx | 4 +- .../CippComponents/CippTenantSelector.jsx | 4 +- .../CippTransportRuleDrawer.jsx | 12 +- .../CippComponents/CippUserActions.jsx | 13 + .../CippComponents/CippUserSwitcher.jsx | 27 + .../CippVariableAutocomplete.jsx | 8 +- .../CippComponents/EnrollmentProfileTabs.jsx | 136 ++ src/components/CippComponents/LicenseCard.jsx | 2 +- src/components/CippComponents/MFACard.jsx | 2 +- .../CippComponents/SecureScoreCard.jsx | 40 +- .../CippComponents/TenantMetricsGrid.jsx | 107 +- .../CippFormPages/CippAddEditUser.jsx | 52 +- .../CippFormPages/CippAddGroupForm.jsx | 5 +- .../CippAddGroupTemplateForm.jsx | 1 - .../CippExchangeSettingsForm.jsx | 4 +- src/components/CippFormPages/CippFormPage.jsx | 39 +- src/components/CippFormPages/CippJSONView.jsx | 3 + .../CippFormPages/CippSchedulerForm.jsx | 8 +- .../CippIntegrationSettings.jsx | 7 +- .../CippPdf/CippBrandingReportPreview.jsx | 12 +- src/components/CippPdf/CippPdfPreview.jsx | 140 ++ .../CippPdf/PermissionsReportButton.jsx | 12 +- .../CippPdf/SharingReportButton.jsx | 12 +- src/components/CippPdf/previewSampleData.js | 12 + .../CippSettings/CippAppServiceDomains.jsx | 4 +- .../CippSettings/CippBrandingSettings.jsx | 2 +- .../CippSettings/CippContainerManagement.jsx | 19 + .../CippSettings/CippGDAP/CippFlowDiagram.jsx | 2 +- .../CippGDAP/CippGDAPTraceResults.jsx | 2 +- .../CippGDAP/CippPathVisualization.jsx | 2 +- .../CippSettings/CippGDAPResults.jsx | 19 +- .../CippSettings/CippPermissionReport.jsx | 77 +- .../CippSettings/CippPermissionResults.jsx | 10 +- .../CippSettings/CippRoleAddEdit.jsx | 530 +++++- src/components/CippSettings/CippRoles.jsx | 68 +- .../CippSettings/CippSSOSettings.jsx | 80 +- .../CippSettings/CippUserManagement.jsx | 2 +- .../CippSettings/CippVersionProperties.jsx | 115 +- .../CippStandards/CippStandardAccordion.jsx | 4 +- .../CippStandards/CippStandardDialog.jsx | 2 +- .../CippTable/CIPPTableToptoolbar.js | 759 ++++---- src/components/CippTable/CippDataTable.js | 869 +++++++-- .../CippTable/CippDataTableButton.jsx | 174 +- .../CippTable/CippDiagnosticsFilter.js | 7 +- .../CippTable/CippGraphExplorerFilter.js | 45 +- .../CippGraphExplorerSimpleFilter.js | 21 +- .../CippTable/CippMobileCardList.jsx | 442 +++++ .../CippTable/CippMobileTableControls.jsx | 312 ++++ src/components/CippTable/CippQueueTracker.js | 14 +- .../CippTable/CippTableCardButton.jsx | 73 + .../CippTable/CippTableFilterSheet.jsx | 215 +++ .../CippTable/toolbar-primitives.js | 103 ++ .../CippTable/util-columnsFromAPI.js | 5 +- .../CippTable/util-mobile-card-slots.js | 143 ++ src/components/CippTable/util-subTables.js | 75 + src/components/CippTable/util-tablemode.js | 44 +- .../CippTestDetailOffCanvas.jsx | 30 +- .../CippWizard/CippIntunePolicy.jsx | 2 +- .../CippWizard/CippPSACredentialsStep.jsx | 11 +- .../CippWizard/CippPSASyncOptions.jsx | 5 +- src/components/CippWizard/CippWizard.jsx | 26 +- .../CippWizard/CippWizardActionsRow.jsx | 47 + .../CippWizard/CippWizardAutopilotImport.jsx | 167 +- .../CippWizard/CippWizardGroupTemplates.jsx | 1 - .../CippWizard/CippWizardOffboarding.jsx | 86 +- src/components/CippWizard/CippWizardPage.jsx | 34 +- .../CippWizard/CippWizardProgressHeader.jsx | 45 + .../CippWizard/CippWizardStepButtons.jsx | 15 +- .../CippWizard/CippWizardVacationActions.jsx | 49 +- .../CippWizardVacationConfirmation.jsx | 56 +- src/components/CippWizard/wizard-steps.js | 16 +- src/components/ExecutiveReportButton.js | 285 +-- src/components/ReleaseNotesDialog.js | 308 +++- .../ReportBuilder/ReportBuilderPDF.js | 13 +- src/components/ShadowAIReportButton.js | 214 ++- src/components/actions-menu.js | 79 +- src/components/images-dialog.js | 2 +- src/contexts/settings-context.js | 3 + src/data/CIPPDBCacheTypes.json | 7 +- src/data/Extensions.json | 26 + src/data/M365Licenses.json | 144 +- src/data/alerts.json | 22 +- src/data/cipp-roles.json | 3 +- src/data/standards.json | 193 +- src/hooks/use-actions-dispatch.jsx | 91 + src/hooks/use-breakpoint.js | 44 + src/hooks/use-history-dismiss.js | 40 + src/hooks/use-sheet-handoff.js | 59 + src/hooks/use-swipe-close-transition.js | 46 + src/layouts/HeaderedTabbedLayout.jsx | 297 ++- src/layouts/TabbedLayout.jsx | 127 +- src/layouts/account-popover.js | 121 +- src/layouts/config.js | 15 +- src/layouts/constants.js | 3 +- src/layouts/index.js | 47 +- src/layouts/mobile-nav-item.js | 7 +- src/layouts/mobile-nav.js | 159 +- src/layouts/notifications-popover.js | 17 +- src/layouts/side-nav-bookmarks.js | 9 +- src/layouts/side-nav.js | 8 +- src/layouts/tab-navigation-context.js | 116 ++ src/layouts/top-nav.js | 110 +- src/pages/_app.js | 100 +- .../authentication/cipp-roles/index.js | 7 +- .../advanced/authentication/cipp-users.js | 11 +- src/pages/cipp/advanced/authentication/sso.js | 2 +- .../advanced/container-management/logs.js | 9 +- .../container-management/worker-health.js | 27 +- .../super-admin/jit-admin-settings.js | 5 +- src/pages/cipp/advanced/table-maintenance.js | 19 +- .../custom-data/schema-extensions/index.js | 5 +- src/pages/cipp/logs/index.js | 44 +- src/pages/cipp/preferences.js | 19 + src/pages/cipp/settings/backend.js | 2 +- src/pages/cipp/settings/branding.js | 2 +- src/pages/cipp/settings/index.js | 2 +- src/pages/cipp/settings/permissions.js | 7 +- src/pages/cipp/settings/siem.js | 2 +- src/pages/copilot/shadow-ai/index.js | 2 +- src/pages/dashboardv2/index.js | 133 +- .../administration/hve-accounts/index.js | 2 +- .../administration/mailbox-rules/index.js | 2 +- .../administration/mailboxes/addshared.jsx | 4 +- .../email/administration/mailboxes/index.js | 2 +- .../email/administration/quarantine/files.js | 14 + .../email/administration/quarantine/index.js | 258 +-- .../administration/quarantine/tabOptions.json | 17 + .../email/administration/quarantine/teams.js | 14 + .../tenant-allow-block-lists/index.js | 2 +- .../SharedMailboxEnabledAccount/index.js | 2 +- .../reports/calendar-permissions/index.js | 2 +- .../email/reports/mailbox-activity/index.js | 2 +- .../email/reports/mailbox-forwarding/index.js | 2 +- .../reports/mailbox-permissions/index.js | 2 +- .../list-quarantine-policies/add.jsx | 8 +- .../email/tools/mailbox-restores/add.jsx | 6 +- .../email/tools/message-encryption/index.js | 226 +++ .../endpoint/MEM/assignment-filters/index.js | 2 +- .../endpoint/MEM/devices/device/index.jsx | 27 +- .../MEM/list-appprotection-policies/index.js | 2 +- .../MEM/list-compliance-policies/index.js | 2 +- src/pages/endpoint/MEM/list-policies/index.js | 2 +- src/pages/endpoint/MEM/list-scripts/index.jsx | 2 +- .../endpoint/MEM/reusable-settings/index.js | 2 +- src/pages/endpoint/applications/list/index.js | 2 +- .../administration/groups/group/index.jsx | 26 +- .../identity/administration/groups/index.js | 274 ++- .../administration/guest-users/index.js | 241 +++ .../administration/users/user/bec.jsx | 16 +- .../users/user/conditional-access.jsx | 8 + .../administration/users/user/edit.jsx | 8 + .../administration/users/user/exchange.jsx | 14 +- .../administration/users/user/index.jsx | 48 +- .../administration/vacation-mode/index.js | 5 + .../reports/inactive-users-report/index.js | 4 +- .../identity/reports/mfa-report/index.js | 2 +- .../identity/reports/signin-report/index.js | 2 +- .../incidents/list-incidents/index.js | 2 +- .../security/reports/mde-onboarding/index.js | 2 +- src/pages/teams-share/onedrive/index.js | 2 +- .../teams-share/permissions-report/index.js | 5 +- src/pages/teams-share/sharepoint/index.js | 2 +- src/pages/teams-share/sharepoint2/index.js | 322 ++++ src/pages/teams-share/sharing-report/index.js | 2 +- .../teams-share/teams/business-voice/index.js | 2 +- .../teams-share/teams/list-team/index.js | 2 +- .../teams-share/teams/teams-activity/index.js | 2 +- .../administration/add-subscription/index.jsx | 4 +- .../alert-configuration/alert.jsx | 8 +- .../alert-configuration/index.js | 42 +- .../applications/app-registration/index.jsx | 12 +- .../app-registration/permissions.jsx | 8 + .../applications/enterprise-app/index.jsx | 12 +- .../enterprise-app/permissions.jsx | 8 + .../tenant/administration/tenants/edit.js | 2 + src/pages/tenant/baselines/alignment/index.js | 99 +- src/pages/tenant/baselines/template.jsx | 61 +- .../tenant/gdap-management/invites/add.js | 5 +- .../tenant/gdap-management/offboarding.js | 6 + .../relationships/relationship/index.js | 2 + .../relationships/relationship/mappings.js | 2 + src/pages/tenant/gdap-management/roles/add.js | 5 +- src/pages/tenant/manage/applied-standards.js | 2 +- .../tenant/manage/configuration-backup.js | 29 +- src/pages/tenant/manage/drift.js | 5 +- src/pages/tenant/manage/edit.js | 2 + src/pages/tenant/manage/history.js | 5 +- .../reports/application-consent/index.js | 2 +- .../reports/graph-office-reports/index.js | 2 +- .../tenant/standards/bpa-report/builder.js | 6 +- src/pages/tenant/standards/bpa-report/view.js | 6 +- src/pages/tenant/standards/templates/index.js | 4 +- src/pages/tenant/tools/geoiplookup/index.js | 12 +- src/pages/tools/breachlookup/index.js | 20 +- .../tools/report-builder/builder/index.js | 23 +- src/pages/unauthenticated.js | 69 +- src/theme/base/create-components.js | 101 + src/utils/cippVersion.js | 5 + src/utils/csv-field-values.js | 50 + src/utils/get-cipp-formatting.js | 40 +- src/utils/get-filtered-portals.js | 37 + src/utils/help-links.js | 41 + src/utils/impersonation.js | 75 + src/utils/is-cloud-pc-device.js | 13 + src/utils/overlay-history.js | 123 ++ src/utils/permission-rules.js | 180 ++ src/utils/render-url-value.jsx | 58 + src/utils/resolve-row-templates.js | 93 + src/utils/support-bundle.js | 246 +++ .../CippCards/CippPageCard.test.jsx | 123 +- .../CippUniversalSearchV2.stories.jsx | 62 + .../CippCards/CippUniversalSearchV2.test.jsx | 125 ++ .../CippCards/mobile-overflow.stories.jsx | 167 ++ .../CippComponents/CippAddUserDrawer.test.jsx | 197 ++ .../CippComponents/CippApiDialog.test.jsx | 34 + .../CippAppPermissionBuilder.stories.jsx | 93 + .../CippComponents/CippAutocomplete.test.jsx | 47 + .../CippAutopilotProfileDrawer.test.jsx | 163 ++ .../CippBottomSheet.stories.jsx | 207 +++ .../CippComponents/CippBreadcrumbNav.test.jsx | 53 +- .../CippExpandableAlert.stories.jsx | 80 + .../CippMobileTenantPicker.stories.jsx | 117 ++ .../CippComponents/CippOffCanvas.test.jsx | 128 +- .../CippPageActionsFab.stories.jsx | 215 +++ .../CippPageActionsFab.test.jsx | 197 ++ .../CippQuarantineDetails.test.jsx | 180 ++ .../CippQuarantineTable.test.jsx | 98 + .../CippReportToolbar.stories.jsx | 99 + .../CippComponents/CippReportToolbar.test.jsx | 237 +++ .../CippComponents/CippSankey.test.jsx | 61 + .../CippSettingsSideBar.test.jsx | 46 + .../CippComponents/CippTabPicker.stories.jsx | 162 ++ .../CippComponents/CippUserSwitcher.test.jsx | 104 ++ .../CippComponents/SecureScoreCard.test.jsx | 44 +- .../CippFormPages/CippFormPage.test.jsx | 173 +- .../CippPdf/CippPdfPreview.test.jsx | 128 ++ .../CippPdf/ReportDialogActions.stories.jsx | 68 + .../CippPermissionReport.test.jsx | 86 + .../CippTable/CIPPTableToptoolbar.test.jsx | 112 ++ .../CippTable/CippDataTable.test.jsx | 728 +++++++- .../CippTable/CippDataTableButton.stories.jsx | 50 + .../CippTable/CippDataTableButton.test.jsx | 67 + .../CippTable/CippDiagnosticsFilter.test.jsx | 47 + .../CippGraphExplorerFilter.test.jsx | 44 + .../CippTable/CippMobileCardList.stories.jsx | 423 +++++ .../CippTable/CippMobileCardList.test.jsx | 368 ++++ .../CippTable/CippQueueTracker.stories.jsx | 230 +-- .../order-columns-by-selection.test.js | 36 + .../CippTable/util-columnsFromAPI.test.jsx | 16 + .../CippTable/util-mobile-card-slots.test.js | 146 ++ .../CippTable/util-subTables.test.js | 62 + .../CippTable/util-tablemode.test.jsx | 20 + .../CippWizardAutopilotImport.stories.jsx | 75 + .../CippWizardAutopilotImport.test.jsx | 85 + .../CippWizard/CippWizardPage.stories.jsx | 83 + .../CippWizardVacationActions.test.jsx | 22 + .../CippWizard/wizard-steps.test.jsx | 100 + .../components/ExecutiveReportButton.test.jsx | 48 +- tests/components/PrivateRoute.test.jsx | 20 + tests/components/ReleaseNotesDialog.test.jsx | 78 +- tests/hooks/use-actions-dispatch.test.jsx | 172 ++ tests/hooks/use-breakpoint.test.jsx | 106 ++ tests/hooks/use-history-dismiss.test.jsx | 93 + tests/hooks/use-sheet-handoff.test.jsx | 64 + tests/layouts/AccountPopover.test.jsx | 99 + tests/layouts/HeaderedTabbedLayout.test.jsx | 127 ++ tests/layouts/MobileNav.stories.jsx | 189 ++ tests/layouts/MobileNav.test.jsx | 77 + tests/layouts/TabbedLayout.test.jsx | 261 +++ tests/layouts/header-overflow.stories.jsx | 90 + tests/layouts/notification-badge.stories.jsx | 92 + tests/lint/mobile-layout-patterns.test.js | 253 +++ tests/pages/MessageEncryptionPage.test.jsx | 136 ++ tests/pages/WorkerHealthPage.test.jsx | 65 +- tests/theme/input-zoom.test.js | 16 + tests/theme/mobile-gutters.test.js | 47 + tests/theme/tooltip-touch.test.jsx | 56 + tests/utils/csv-field-values.test.js | 68 + tests/utils/get-filtered-portals.test.js | 49 + tests/utils/impersonation.test.js | 119 ++ tests/utils/overlay-history.test.js | 170 ++ tests/utils/permission-rules.test.js | 151 ++ tests/utils/resolve-row-templates.test.js | 121 ++ tests/viewport.js | 41 + yarn.lock | 1135 +++++++----- 355 files changed, 27103 insertions(+), 3632 deletions(-) create mode 100644 src/components/CippComponents/CippAppRegistrationSwitcher.jsx create mode 100644 src/components/CippComponents/CippBottomSheet.jsx create mode 100644 src/components/CippComponents/CippEnterpriseAppSwitcher.jsx create mode 100644 src/components/CippComponents/CippEntitySwitcher.jsx create mode 100644 src/components/CippComponents/CippExpandableAlert.jsx create mode 100644 src/components/CippComponents/CippGdapRelationshipSwitcher.jsx create mode 100644 src/components/CippComponents/CippImpersonationBanner.jsx create mode 100644 src/components/CippComponents/CippMobileTenantPicker.jsx create mode 100644 src/components/CippComponents/CippPageActionsFab.jsx create mode 100644 src/components/CippComponents/CippQuarantineDetails.jsx create mode 100644 src/components/CippComponents/CippQuarantineTable.jsx create mode 100644 src/components/CippComponents/CippSharePointBrowserBanner.jsx create mode 100644 src/components/CippComponents/CippSharePointBrowserPermissions.jsx create mode 100644 src/components/CippComponents/CippSharePointBrowserProperties.jsx create mode 100644 src/components/CippComponents/CippSharePointBrowserStorage.jsx create mode 100644 src/components/CippComponents/CippSharePointFolderView.jsx create mode 100644 src/components/CippComponents/CippSupportBundleDialog.jsx create mode 100644 src/components/CippComponents/CippTabNavigationSection.jsx create mode 100644 src/components/CippComponents/CippTabPicker.jsx create mode 100644 src/components/CippComponents/CippUserSwitcher.jsx create mode 100644 src/components/CippPdf/CippPdfPreview.jsx create mode 100644 src/components/CippTable/CippMobileCardList.jsx create mode 100644 src/components/CippTable/CippMobileTableControls.jsx create mode 100644 src/components/CippTable/CippTableCardButton.jsx create mode 100644 src/components/CippTable/CippTableFilterSheet.jsx create mode 100644 src/components/CippTable/toolbar-primitives.js create mode 100644 src/components/CippTable/util-mobile-card-slots.js create mode 100644 src/components/CippTable/util-subTables.js create mode 100644 src/components/CippWizard/CippWizardActionsRow.jsx create mode 100644 src/components/CippWizard/CippWizardProgressHeader.jsx create mode 100644 src/hooks/use-actions-dispatch.jsx create mode 100644 src/hooks/use-breakpoint.js create mode 100644 src/hooks/use-history-dismiss.js create mode 100644 src/hooks/use-sheet-handoff.js create mode 100644 src/hooks/use-swipe-close-transition.js create mode 100644 src/layouts/tab-navigation-context.js create mode 100644 src/pages/email/administration/quarantine/files.js create mode 100644 src/pages/email/administration/quarantine/tabOptions.json create mode 100644 src/pages/email/administration/quarantine/teams.js create mode 100644 src/pages/email/tools/message-encryption/index.js create mode 100644 src/pages/identity/administration/guest-users/index.js create mode 100644 src/pages/teams-share/sharepoint2/index.js create mode 100644 src/utils/csv-field-values.js create mode 100644 src/utils/get-filtered-portals.js create mode 100644 src/utils/help-links.js create mode 100644 src/utils/impersonation.js create mode 100644 src/utils/is-cloud-pc-device.js create mode 100644 src/utils/overlay-history.js create mode 100644 src/utils/permission-rules.js create mode 100644 src/utils/render-url-value.jsx create mode 100644 src/utils/resolve-row-templates.js create mode 100644 src/utils/support-bundle.js create mode 100644 tests/components/CippCards/CippUniversalSearchV2.stories.jsx create mode 100644 tests/components/CippCards/CippUniversalSearchV2.test.jsx create mode 100644 tests/components/CippCards/mobile-overflow.stories.jsx create mode 100644 tests/components/CippComponents/CippAddUserDrawer.test.jsx create mode 100644 tests/components/CippComponents/CippAppPermissionBuilder.stories.jsx create mode 100644 tests/components/CippComponents/CippAutopilotProfileDrawer.test.jsx create mode 100644 tests/components/CippComponents/CippBottomSheet.stories.jsx create mode 100644 tests/components/CippComponents/CippExpandableAlert.stories.jsx create mode 100644 tests/components/CippComponents/CippMobileTenantPicker.stories.jsx create mode 100644 tests/components/CippComponents/CippPageActionsFab.stories.jsx create mode 100644 tests/components/CippComponents/CippPageActionsFab.test.jsx create mode 100644 tests/components/CippComponents/CippQuarantineDetails.test.jsx create mode 100644 tests/components/CippComponents/CippQuarantineTable.test.jsx create mode 100644 tests/components/CippComponents/CippReportToolbar.stories.jsx create mode 100644 tests/components/CippComponents/CippReportToolbar.test.jsx create mode 100644 tests/components/CippComponents/CippSankey.test.jsx create mode 100644 tests/components/CippComponents/CippSettingsSideBar.test.jsx create mode 100644 tests/components/CippComponents/CippTabPicker.stories.jsx create mode 100644 tests/components/CippComponents/CippUserSwitcher.test.jsx create mode 100644 tests/components/CippPdf/CippPdfPreview.test.jsx create mode 100644 tests/components/CippPdf/ReportDialogActions.stories.jsx create mode 100644 tests/components/CippSettings/CippPermissionReport.test.jsx create mode 100644 tests/components/CippTable/CippDiagnosticsFilter.test.jsx create mode 100644 tests/components/CippTable/CippMobileCardList.stories.jsx create mode 100644 tests/components/CippTable/CippMobileCardList.test.jsx create mode 100644 tests/components/CippTable/order-columns-by-selection.test.js create mode 100644 tests/components/CippTable/util-mobile-card-slots.test.js create mode 100644 tests/components/CippTable/util-subTables.test.js create mode 100644 tests/components/CippWizard/CippWizardAutopilotImport.stories.jsx create mode 100644 tests/components/CippWizard/CippWizardAutopilotImport.test.jsx create mode 100644 tests/components/CippWizard/CippWizardPage.stories.jsx create mode 100644 tests/components/CippWizard/wizard-steps.test.jsx create mode 100644 tests/hooks/use-actions-dispatch.test.jsx create mode 100644 tests/hooks/use-breakpoint.test.jsx create mode 100644 tests/hooks/use-history-dismiss.test.jsx create mode 100644 tests/hooks/use-sheet-handoff.test.jsx create mode 100644 tests/layouts/AccountPopover.test.jsx create mode 100644 tests/layouts/HeaderedTabbedLayout.test.jsx create mode 100644 tests/layouts/MobileNav.stories.jsx create mode 100644 tests/layouts/MobileNav.test.jsx create mode 100644 tests/layouts/TabbedLayout.test.jsx create mode 100644 tests/layouts/header-overflow.stories.jsx create mode 100644 tests/layouts/notification-badge.stories.jsx create mode 100644 tests/lint/mobile-layout-patterns.test.js create mode 100644 tests/pages/MessageEncryptionPage.test.jsx create mode 100644 tests/theme/input-zoom.test.js create mode 100644 tests/theme/mobile-gutters.test.js create mode 100644 tests/theme/tooltip-touch.test.jsx create mode 100644 tests/utils/csv-field-values.test.js create mode 100644 tests/utils/get-filtered-portals.test.js create mode 100644 tests/utils/impersonation.test.js create mode 100644 tests/utils/overlay-history.test.js create mode 100644 tests/utils/permission-rules.test.js create mode 100644 tests/utils/resolve-row-templates.test.js create mode 100644 tests/viewport.js diff --git a/package.json b/package.json index ff1da83ceb4b..89ce0d787153 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cipp", - "version": "10.8.5", + "version": "10.9.0", "author": "CIPP Contributors", "homepage": "https://cipp.app/", "bugs": { @@ -8,7 +8,7 @@ }, "license": "AGPL-3.0", "engines": { - "node": "^22.22.0" + "node": "^22.22.2" }, "repository": { "type": "git", @@ -60,7 +60,7 @@ "@tiptap/extension-table": "^3.20.5", "@tiptap/pm": "^3.29.2", "@tiptap/react": "^3.20.5", - "@tiptap/starter-kit": "^3.20.5", + "@tiptap/starter-kit": "^3.29.2", "@vvo/tzdb": "^6.198.0", "apexcharts": "6.6.1", "axios": "1.18.1", @@ -91,7 +91,7 @@ "react-apexcharts": "2.1.1", "react-beautiful-dnd": "13.1.1", "react-dom": "19.2.8", - "react-dropzone": "15.0.0", + "react-dropzone": "20.0.0", "react-error-boundary": "^6.1.2", "react-hook-form": "^7.76.1", "react-hot-toast": "2.6.0", @@ -135,12 +135,12 @@ "eslint": "^9.39.4", "eslint-config-next": "^16.2.10", "eslint-config-prettier": "^10.1.8", - "jsdom": "29.0.1", + "jsdom": "30.0.1", "msw": "2.12.14", "msw-storybook-addon": "3.0.0", "playwright": "1.59.1", "prettier": "^3.9.6", - "storybook": "10.3.5", + "storybook": "10.5.7", "typescript": "5.9.3", "vite": "7.3.6", "vitest": "4.1.10" diff --git a/public/version.json b/public/version.json index 734751ab5a2f..b4196392f644 100644 --- a/public/version.json +++ b/public/version.json @@ -1,3 +1,3 @@ { - "version": "10.8.5" -} \ No newline at end of file + "version": "10.9.0" +} diff --git a/src/api/ApiCall.jsx b/src/api/ApiCall.jsx index 579161a8225a..1a74d97210fe 100644 --- a/src/api/ApiCall.jsx +++ b/src/api/ApiCall.jsx @@ -4,6 +4,7 @@ import { useDispatch } from "react-redux"; import { showToast } from "../store/toasts"; import { getCippError } from "../utils/get-cipp-error"; import { buildVersionedHeaders } from "../utils/cippVersion"; +import { impersonationCacheParams } from "../utils/impersonation"; const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const wildcardToRegExp = (pattern) => @@ -71,7 +72,7 @@ export function ApiGetCall(props) { const element = data[i]; const response = await axios.get(url, { signal: signal, - params: element, + params: { ...element, ...impersonationCacheParams() }, headers: await buildVersionedHeaders(), }); results.push(response.data); @@ -109,7 +110,7 @@ export function ApiGetCall(props) { } else { const response = await axios.get(url, { signal: url === "/api/tenantFilter" ? null : signal, - params: data, + params: { ...data, ...impersonationCacheParams() }, headers: await buildVersionedHeaders(), responseType: responseType, }); @@ -292,7 +293,7 @@ export function ApiGetCallWithPagination({ queryFn: async ({ pageParam = null, signal }) => { const response = await axios.get(url, { signal: signal, - params: { ...data, ...pageParam }, + params: { ...data, ...pageParam, ...impersonationCacheParams() }, headers: await buildVersionedHeaders(), }); return response.data; diff --git a/src/components/BECRemediationReportButton.js b/src/components/BECRemediationReportButton.js index dd67d546cb18..5c33c927c1d9 100644 --- a/src/components/BECRemediationReportButton.js +++ b/src/components/BECRemediationReportButton.js @@ -12,7 +12,8 @@ import { CircularProgress, } from '@mui/material' import { PictureAsPdf, Download, Close } from '@mui/icons-material' -import { PDFViewer, PDFDownloadLink } from '@react-pdf/renderer' +import { PDFDownloadLink } from '@react-pdf/renderer' +import { CippPdfPreview } from './CippPdf/CippPdfPreview' import { useReportVariables } from './CippPdf/useReportVariables' import { useBrandingSettings } from './CippPdf/useBrandingSettings' import { @@ -1268,7 +1269,12 @@ export const BECRemediationReportButton = ({ userData, becData, tenantName }) => {hasData && ( - + tenantName={tenantName} variables={variables} /> - + )} diff --git a/src/components/CippAllTenants/AllTenantsPrimitives.jsx b/src/components/CippAllTenants/AllTenantsPrimitives.jsx index 6377b0ea8444..a36ed93b648d 100644 --- a/src/components/CippAllTenants/AllTenantsPrimitives.jsx +++ b/src/components/CippAllTenants/AllTenantsPrimitives.jsx @@ -552,7 +552,7 @@ export const AllTenantsTrendChart = ({ /** Band heading that separates the dashboard into Portfolio / Security / Operations. */ export const AllTenantsBandHeading = ({ title, description }) => ( - { + if (!dateAdded) return false + return differenceInDays(new Date(), new Date(dateAdded)) <= 30 +} + +// Extract the base compliance framework from a benchmark tag (same buckets as the +// classic standards picker) so 'CIS M365 3.0 (1.1.1)' style tags filter as one group. +const extractTagFramework = (tag) => { + if (tag.startsWith('CIS M365')) { + const versionMatch = tag.match(/CIS M365 (\d+\.\d+)/) + return versionMatch ? `CIS M365 ${versionMatch[1]}` : 'CIS M365' + } + if (tag.startsWith('CISA ')) return 'CISA' + if (tag.startsWith('EIDSCA.')) return 'EIDSCA' + if (tag.startsWith('Essential 8')) return 'Essential 8' + if (tag.startsWith('NIST CSF')) { + const versionMatch = tag.match(/NIST CSF (\d+\.\d+)/) + return versionMatch ? `NIST CSF ${versionMatch[1]}` : 'NIST CSF' + } + if (tag.startsWith('exo_')) return 'Secure Score - Exchange' + if (tag.startsWith('mdo_')) return 'Secure Score - Defender' + if (tag.startsWith('spo_')) return 'Secure Score - SharePoint' + if (tag.startsWith('mip_')) return 'Secure Score - Purview' + return null +} + +// One sort control instead of separate field + direction dropdowns: each option +// carries its natural reading order. +const sortOptions = [ + { label: 'Name (A-Z)', value: 'label-asc' }, + { label: 'Newest first', value: 'addedDate-desc' }, + { label: 'Category', value: 'category-asc' }, + { label: 'Impact (High-Low)', value: 'impact-desc' }, +] + +// Benchmark tags on a card: the first few visible, the rest behind a toggle chip. +const StandardTagChips = ({ tags, max = 4 }) => { + const [showAll, setShowAll] = useState(false) + if (tags.length === 0) return null + const visible = showAll ? tags : tags.slice(0, max) + return ( + + {visible.map((tag) => ( + + ))} + {tags.length > max && ( + setShowAll((prev) => !prev)} + sx={{ fontSize: '0.7rem', height: 20 }} + /> + )} + + ) +} + +// Browse-and-add picker for Baseline standard definitions. The filters stay +// visible (no collapsed panel to discover): search + four multi-select filters on +// one row, then a slim toolbar with the result count, added-state filter, sort, +// and the card/list view toggle. export const CippBaselineStandardDialog = ({ open, onClose, @@ -35,172 +122,577 @@ export const CippBaselineStandardDialog = ({ onToggle, }) => { const [search, setSearch] = useState('') - const [category, setCategory] = useState('All') + const [viewMode, setViewMode] = useState('card') + const [sortOption, setSortOption] = useState(sortOptions[0]) + const [selectedCategories, setSelectedCategories] = useState([]) + const [selectedImpacts, setSelectedImpacts] = useState([]) + const [selectedRecommendedBy, setSelectedRecommendedBy] = useState([]) + const [selectedTagFrameworks, setSelectedTagFrameworks] = useState([]) + const [statusFilter, setStatusFilter] = useState('all') - const categories = useMemo( - () => ['All', ...new Set(catalog.map((standard) => standard.cat))], - [catalog] - ) + const { allCategories, allImpacts, allRecommendedBy, allTagFrameworks } = + useMemo(() => { + const categorySet = new Set() + const impactSet = new Set() + const recommendedBySet = new Set() + const tagFrameworkSet = new Set() + for (const standard of catalog) { + if (standard.cat) categorySet.add(standard.cat) + if (standard.impact) impactSet.add(standard.impact) + for (const source of standard.recommendedBy ?? []) { + recommendedBySet.add(source) + } + for (const tag of standard.tag ?? []) { + const framework = extractTagFramework(tag) + if (framework) tagFrameworkSet.add(framework) + } + } + const impactOrder = ['Low Impact', 'Medium Impact', 'High Impact'] + return { + allCategories: [...categorySet].sort(), + allImpacts: [...impactSet].sort( + (a, b) => impactOrder.indexOf(a) - impactOrder.indexOf(b) + ), + allRecommendedBy: [...recommendedBySet].sort(), + allTagFrameworks: [...tagFrameworkSet].sort(), + } + }, [catalog]) + + const toOptions = (values, labelOf = (value) => value) => + values.map((value) => ({ label: labelOf(value), value })) + + // Multi-instance standards count 'Name#n' keys; each click adds another instance. + const instanceCountOf = (standard) => + selectedStandards.filter((key) => key.split('#')[0] === standard.name) + .length const filtered = catalog.filter((standard) => { - if (category !== 'All' && standard.cat !== category) return false - if (!search) return true const query = search.toLowerCase() - return ( + const matchesSearch = + !query || standard.label.toLowerCase().includes(query) || (standard.helpText ?? '').toLowerCase().includes(query) || - (standard.tag ?? []).some((tag) => tag.toLowerCase().includes(query)) + (standard.tag ?? []).some((tag) => tag.toLowerCase().includes(query)) || + (standard.appliesToTest ?? []).some((testId) => + testId.toLowerCase().includes(query) + ) + const matchesCategory = + selectedCategories.length === 0 || + selectedCategories.includes(standard.cat) + const matchesImpact = + selectedImpacts.length === 0 || selectedImpacts.includes(standard.impact) + const matchesRecommendedBy = + selectedRecommendedBy.length === 0 || + (standard.recommendedBy ?? []).some((source) => + selectedRecommendedBy.includes(source) + ) + const matchesTagFramework = + selectedTagFrameworks.length === 0 || + (standard.tag ?? []).some((tag) => { + const framework = extractTagFramework(tag) + return framework && selectedTagFrameworks.includes(framework) + }) + const isSelected = instanceCountOf(standard) > 0 + const matchesStatusFilter = + statusFilter === 'all' || + (statusFilter === 'added' && isSelected) || + (statusFilter === 'notAdded' && !isSelected) + return ( + matchesSearch && + matchesCategory && + matchesImpact && + matchesRecommendedBy && + matchesTagFramework && + matchesStatusFilter ) }) + const [sortBy, sortOrder] = sortOption.value.split('-') + const sorted = [...filtered].sort((a, b) => { + let aValue + let bValue + switch (sortBy) { + case 'addedDate': + aValue = new Date(a.addedDate || '1900-01-01') + bValue = new Date(b.addedDate || '1900-01-01') + break + case 'category': + aValue = a.cat?.toLowerCase() ?? '' + bValue = b.cat?.toLowerCase() ?? '' + break + case 'impact': { + const impactOrder = { + 'High Impact': 3, + 'Medium Impact': 2, + 'Low Impact': 1, + } + aValue = impactOrder[a.impact] ?? 0 + bValue = impactOrder[b.impact] ?? 0 + break + } + default: + aValue = a.label.toLowerCase() + bValue = b.label.toLowerCase() + } + if (aValue < bValue) return sortOrder === 'asc' ? -1 : 1 + if (aValue > bValue) return sortOrder === 'asc' ? 1 : -1 + return 0 + }) + + const hasActiveFilters = + search !== '' || + selectedCategories.length > 0 || + selectedImpacts.length > 0 || + selectedRecommendedBy.length > 0 || + selectedTagFrameworks.length > 0 || + statusFilter !== 'all' + const selectedCount = selectedStandards.length - const handleClose = () => { + const clearAllFilters = () => { setSearch('') - setCategory('All') + setSelectedCategories([]) + setSelectedImpacts([]) + setSelectedRecommendedBy([]) + setSelectedTagFrameworks([]) + setStatusFilter('all') + } + + const handleClose = () => { + clearAllFilters() + setSortOption(sortOptions[0]) + setViewMode('card') onClose() } + const addButton = (standard, instanceCount) => { + const isSelected = instanceCount > 0 + return ( + + ) + } + return ( Add Standards to Stage - setSearch(event.target.value)} - InputProps={{ - startAdornment: ( - - - - ), - }} - /> - - {categories.map((entry) => ( - setCategory(entry)} + + + setSearch(event.target.value)} + autoComplete="off" + placeholder="Search by name, description, or benchmark tag..." + InputProps={{ + startAdornment: ( + + ), + }} + /> + + + + setSelectedCategories( + Array.isArray(newValue) + ? newValue.map((option) => option.value) + : [] + ) + } /> - ))} + + + + setSelectedImpacts( + Array.isArray(newValue) + ? newValue.map((option) => option.value) + : [] + ) + } + /> + + + + setSelectedRecommendedBy( + Array.isArray(newValue) + ? newValue.map((option) => option.value) + : [] + ) + } + /> + + + + setSelectedTagFrameworks( + Array.isArray(newValue) + ? newValue.map((option) => option.value) + : [] + ) + } + /> + + + + + + + Showing {sorted.length} of {catalog.length} standards + + {hasActiveFilters && ( + + )} + + + { + if (newValue !== null) setStatusFilter(newValue) + }} + > + All + Added + Not added + + + { + if (newValue) setSortOption(newValue) + }} + /> + + { + if (newViewMode !== null) setViewMode(newViewMode) + }} + > + + + + + + + + + + + + - - {filtered.map((standard) => { - // Multi-instance standards count 'Name#n' keys; each click adds another instance. - const instanceCount = selectedStandards.filter( - (key) => key.split('#')[0] === standard.name - ).length - const isSelected = instanceCount > 0 - return ( - - + + No standards match your search and filter criteria + + + Try adjusting your search terms or clearing some filters + + + )} + + {viewMode === 'card' ? ( + + {sorted.map((standard) => { + const instanceCount = instanceCountOf(standard) + const isSelected = instanceCount > 0 + const benchmarkTags = (standard.tag ?? []).filter( + (tag) => !tag.toLowerCase().includes('impact') + ) + return ( + + + + + {standard.label} + + + {standard.cat} + + + + {standard.secureScoreImpact > 0 && ( + + + + )} + {(standard.recommendedBy ?? []).map((source) => ( + + ))} + {isNewStandard(standard.addedDate) && ( + + )} + + + {standard.helpText} + + + + + {addButton(standard, instanceCount)} + + + + ) + })} + + ) : ( + + {sorted.map((standard) => { + const instanceCount = instanceCountOf(standard) + const isSelected = instanceCount > 0 + return ( + - - - {standard.label} - - - {standard.cat} - - - - {standard.secureScoreImpact > 0 && ( - + + + {standard.label} + + {isNewStandard(standard.addedDate) && ( - - )} - {(standard.recommendedBy ?? []).map((source) => ( + )} - ))} - - - {standard.helpText} - - - - - - - - ) - })} - {filtered.length === 0 && ( - - - No standards match this search. - - - )} - + + + } + secondary={ + + + {standard.helpText} + + + {(standard.tag ?? []) + .filter( + (tag) => !tag.toLowerCase().includes('impact') + ) + .slice(0, 3) + .map((tag) => ( + + ))} + {(standard.recommendedBy ?? []).length > 0 && ( + + • Recommended by:{' '} + {standard.recommendedBy.join(', ')} + + )} + {standard.secureScoreImpact > 0 && ( + + • +{standard.secureScoreImpact} Secure Score pts + + )} + + + } + sx={{ pr: 22 }} + /> + + {addButton(standard, instanceCount)} + + + ) + })} + + )} diff --git a/src/components/CippBaselines/CippBaselineStandardItem.jsx b/src/components/CippBaselines/CippBaselineStandardItem.jsx index a2ac421247cd..179f03ea4fec 100644 --- a/src/components/CippBaselines/CippBaselineStandardItem.jsx +++ b/src/components/CippBaselines/CippBaselineStandardItem.jsx @@ -86,9 +86,12 @@ export const CippBaselineStandardItem = ({ const watched = useWatch({ control: formControl.control, name: fieldBase }) // Identity-carrying standards (CA/Intune templates via instanceIdentity, manual tasks // via taskName) title as '
    + )} + {analyzed?.attachments?.length > 0 && ( +
    + + + + File Name + Threat + Malware Family + Size + SHA256 + + + + {analyzed.attachments.map((attachment, index) => ( + + + {attachment.fileName} + + + + + {attachment.malwareFamily} + {formatBytes(attachment.fileSize)} + + {attachment.sha256 && ( + + )} + + + ))} + +
    +
    + )} + + + ) +} + +export default CippQuarantineDetails diff --git a/src/components/CippComponents/CippQuarantineTable.jsx b/src/components/CippComponents/CippQuarantineTable.jsx new file mode 100644 index 000000000000..0f59f695fda9 --- /dev/null +++ b/src/components/CippComponents/CippQuarantineTable.jsx @@ -0,0 +1,555 @@ +import { useEffect, useState } from 'react' +import { + CircularProgress, + Dialog, + DialogContent, + DialogTitle, + IconButton, + Skeleton, + Typography, +} from '@mui/material' +import { Block, Close, Done, DoneAll } from '@mui/icons-material' +import { + ArrowDownTrayIcon, + ArrowTopRightOnSquareIcon, + CodeBracketIcon, + DocumentTextIcon, + EyeIcon, + FlagIcon, + NoSymbolIcon, + TrashIcon, +} from '@heroicons/react/24/outline' +import { CippTablePage } from './CippTablePage.jsx' +import { CippMessageViewer } from './CippMessageViewer.jsx' +import { CippQuarantineDetails } from './CippQuarantineDetails.jsx' +import { CippDataTable } from '../CippTable/CippDataTable' +import { ApiGetCall, ApiPostCall } from '../../api/ApiCall' +import { useSettings } from '../../hooks/use-settings' + +const traceDetailColumns = [ + 'Received', + 'Status', + 'SenderAddress', + 'RecipientAddress', +] + +const releaseStatusFilters = [ + { + filterName: 'Not Released', + value: [{ id: 'ReleaseStatus', value: 'NOTRELEASED' }], + type: 'column', + filterType: 'equal', + }, + { + filterName: 'Released', + value: [{ id: 'ReleaseStatus', value: 'RELEASED' }], + type: 'column', + filterType: 'equal', + }, + { + filterName: 'Requested', + value: [{ id: 'ReleaseStatus', value: 'REQUESTED' }], + type: 'column', + filterType: 'equal', + }, +] + +const quarantineReasonFilters = [ + { filterName: 'High Confidence Phishing', value: 'HighConfPhish' }, + { filterName: 'Phishing', value: 'Phish' }, + { filterName: 'Spam', value: 'Spam' }, + { filterName: 'Malware', value: 'Malware' }, + { filterName: 'Bulk', value: 'Bulk' }, + { filterName: 'Transport Rule', value: 'TransportRule' }, +].map(({ filterName, value }) => ({ + filterName, + value: [{ id: 'Type', value }], + type: 'column', + filterType: 'equal', +})) + +const pageTitles = { + Email: 'Quarantine - Email', + SharePointOnline: 'Quarantine - Files', + Teams: 'Quarantine - Teams Messages', +} + +export const CippQuarantineTable = ({ entityType = 'Email' }) => { + const tenantFilter = useSettings().currentTenant + const isEmail = entityType === 'Email' + const queryKey = `MailQuarantine-${entityType}-${tenantFilter}` + + // In the AllTenants view each row belongs to a different tenant (row.Tenant); per-message + // actions must target that tenant rather than the page-level "AllTenants" selection. Falls back + // to the page tenant for the normal single-tenant view. + const resolveTenant = (row) => + tenantFilter === 'AllTenants' ? (row?.Tenant ?? tenantFilter) : tenantFilter + + // Preview message dialog + const [messageRow, setMessageRow] = useState(null) + const [dialogOpen, setDialogOpen] = useState(false) + + // Message headers dialog + const [headerRow, setHeaderRow] = useState(null) + const [headerDialogOpen, setHeaderDialogOpen] = useState(false) + + // Download message state + const [downloadRow, setDownloadRow] = useState(null) + + // Message trace dialog + const [traceDialogOpen, setTraceDialogOpen] = useState(false) + const [traceDetails, setTraceDetails] = useState([]) + const [traceMessageId, setTraceMessageId] = useState(null) + const [traceTenant, setTraceTenant] = useState(null) + const [messageSubject, setMessageSubject] = useState(null) + + const messageTenant = resolveTenant(messageRow) + const getMessageContents = ApiGetCall({ + url: '/api/ListMailQuarantineMessage', + data: { + tenantFilter: messageTenant, + Identity: messageRow?.Identity, + }, + waiting: Boolean(messageRow), + queryKey: `ListMailQuarantineMessage-${messageTenant}-${messageRow?.Identity}`, + }) + + const headerTenant = resolveTenant(headerRow) + const getMessageHeaders = ApiGetCall({ + url: '/api/ListMailQuarantineMessageHeader', + data: { + tenantFilter: headerTenant, + Identity: headerRow?.Identity, + }, + waiting: Boolean(headerRow), + queryKey: `ListMailQuarantineMessageHeader-${headerTenant}-${headerRow?.Identity}`, + }) + + const downloadTenant = resolveTenant(downloadRow) + const getMessageDownload = ApiGetCall({ + url: '/api/ListMailQuarantineMessage', + data: { + tenantFilter: downloadTenant, + Identity: downloadRow?.Identity, + }, + waiting: Boolean(downloadRow), + queryKey: `ListMailQuarantineMessageDownload-${downloadTenant}-${downloadRow?.Identity}`, + }) + + const getMessageTraceDetails = ApiPostCall({ + urlFromData: true, + queryKey: `MessageTraceDetail-${traceTenant}-${traceMessageId}`, + onResult: (result) => { + setTraceDetails(result) + }, + }) + + // CippPropertyListCard calls customFunction(actionItem, rowData, {}); table rows call + // customFunction(rowData). Accept both signatures by detecting which arg carries Identity. + const resolveRow = (...args) => (args[0]?.Identity ? args[0] : args[1]) + + const viewMessage = (...args) => { + const row = resolveRow(...args) + setMessageRow(row) + setDialogOpen(true) + } + + const viewHeaders = (...args) => { + const row = resolveRow(...args) + setHeaderRow(row) + setHeaderDialogOpen(true) + } + + const downloadMessage = (...args) => { + const row = resolveRow(...args) + setDownloadRow(row) + } + + const viewMessageTrace = (...args) => { + const row = resolveRow(...args) + const rowTenant = resolveTenant(row) + setTraceTenant(rowTenant) + setTraceMessageId(row.MessageId) + getMessageTraceDetails.mutate({ + url: '/api/ListMessageTrace', + data: { + tenantFilter: rowTenant, + messageId: row.MessageId, + }, + }) + setMessageSubject(row.Subject) + setTraceDialogOpen(true) + } + + const openInDefender = (...args) => { + const row = resolveRow(...args) + const networkMessageId = + row.NetworkMessageId ?? row.Identity?.split('\\')[0] + const recipient = Array.isArray(row.RecipientAddress) + ? row.RecipientAddress[0] + : row.RecipientAddress + const receivedTime = row.ReceivedTime + ? new Date(row.ReceivedTime).toISOString() + : '' + let url = + `https://security.microsoft.com/emailentityV2?id=${encodeURIComponent(networkMessageId)}` + + `&recipient=${encodeURIComponent(recipient ?? '')}` + + `&startTime=${encodeURIComponent(receivedTime)}` + + `&endTime=${encodeURIComponent(receivedTime)}` + + `&contentonly=1` + + `&subject=${encodeURIComponent(row.Subject ?? '')}` + + `&entityId=${encodeURIComponent(`${networkMessageId}_${recipient ?? ''}`)}` + if (row.CustomerId) { + url += `&tid=${row.CustomerId}` + } + window.open(url, '_blank') + } + + useEffect(() => { + if ( + downloadRow && + getMessageDownload.isSuccess && + getMessageDownload.data?.Message + ) { + const networkMessageId = + downloadRow.NetworkMessageId ?? downloadRow.Identity?.split('\\')[0] + const fileName = `${( + downloadRow.Subject || + networkMessageId || + 'quarantined-message' + ) + .replace(/[\\/:*?"<>|]/g, '_') + .slice(0, 100)}.eml` + // Use the raw base64 export when available to preserve non-UTF-8 MIME content + const emlBase64 = getMessageDownload.data.EmlBase64 + let blob + if (emlBase64) { + const bytes = Uint8Array.from(atob(emlBase64), (c) => c.charCodeAt(0)) + blob = new Blob([bytes], { type: 'message/rfc822' }) + } else { + blob = new Blob([getMessageDownload.data.Message], { + type: 'message/rfc822', + }) + } + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = fileName + link.click() + URL.revokeObjectURL(url) + setDownloadRow(null) + } + }, [getMessageDownload.isSuccess, getMessageDownload.data, downloadRow]) + + const actions = [ + { + label: 'Release', + type: 'POST', + url: '/api/ExecQuarantineManagement', + multiPost: true, + data: { + tenantFilter: 'Tenant', + Identity: 'Identity', + Type: '!Release', + }, + confirmText: 'Are you sure you want to release this message?', + icon: , + condition: (row) => row.ReleaseStatus !== 'RELEASED', + }, + ...(isEmail + ? [ + { + label: 'Release & Allow Sender', + type: 'POST', + url: '/api/ExecQuarantineManagement', + multiPost: true, + data: { + tenantFilter: 'Tenant', + Identity: 'Identity', + Type: '!Release', + AllowSender: true, + SenderAddress: 'SenderAddress', + PolicyName: 'PolicyName', + }, + confirmText: + 'Are you sure you want to release this email and add the sender to the whitelist?', + icon: , + condition: (row) => row.ReleaseStatus !== 'RELEASED', + }, + { + label: 'Deny', + type: 'POST', + url: '/api/ExecQuarantineManagement', + multiPost: true, + data: { + tenantFilter: 'Tenant', + Identity: 'Identity', + Type: '!Deny', + RecipientAddress: 'RecipientAddress', + }, + confirmText: 'Are you sure you want to deny this message?', + icon: , + condition: (row) => row.ReleaseStatus === 'REQUESTED', + }, + ] + : []), + { + label: 'Delete from Quarantine', + type: 'POST', + url: '/api/ExecQuarantineManagement', + multiPost: true, + data: { + tenantFilter: 'Tenant', + Identity: 'Identity', + Type: '!Delete', + }, + confirmText: + 'Are you sure you want to permanently delete this message from quarantine? This action cannot be undone.', + icon: , + color: 'danger', + condition: (row) => row.ReleaseStatus !== 'RELEASED', + }, + ...(isEmail + ? [ + { + label: 'Preview Message', + noConfirm: true, + customFunction: viewMessage, + icon: , + hideBulk: true, + }, + { + label: 'View Message Headers', + noConfirm: true, + customFunction: viewHeaders, + icon: , + hideBulk: true, + }, + { + label: 'Download Message (.eml)', + noConfirm: true, + customFunction: downloadMessage, + icon: , + hideBulk: true, + }, + { + label: 'View Message Trace', + noConfirm: true, + customFunction: viewMessageTrace, + icon: , + hideBulk: true, + }, + { + label: 'Submit to Microsoft for Review', + type: 'POST', + url: '/api/ExecMailQuarantineSubmit', + data: { + tenantFilter: 'Tenant', + Identity: 'Identity', + RecipientAddress: 'RecipientAddress', + }, + fields: [ + { + type: 'autoComplete', + name: 'category', + label: 'Submission category', + multiple: false, + creatable: false, + options: [ + { + label: 'Clean - should not have been quarantined', + value: 'notJunk', + }, + { label: 'Spam', value: 'spam' }, + { label: 'Phishing', value: 'phishing' }, + { label: 'Malware', value: 'malware' }, + ], + validators: { required: 'Please select a category' }, + }, + ], + confirmText: 'Submit "[Subject]" to Microsoft for analysis?', + icon: , + hideBulk: true, + }, + { + label: 'Block Sender', + type: 'POST', + url: '/api/AddTenantAllowBlockList', + data: { + tenantID: 'Tenant', + entries: 'SenderAddress', + listType: '!Sender', + listMethod: '!Block', + }, + fields: [ + { + type: 'switch', + name: 'NoExpiration', + label: 'Never expire (default: expires after 30 days)', + }, + { + type: 'textField', + name: 'notes', + label: 'Notes (optional)', + }, + ], + confirmText: + 'Block sender [SenderAddress] by adding an entry to the Tenant Allow/Block List?', + icon: , + }, + { + label: 'Open Email Entity in Defender', + noConfirm: true, + customFunction: openInDefender, + icon: , + hideBulk: true, + }, + ] + : []), + ] + + const offCanvas = { + size: 'lg', + actions: actions, + actionsPosition: 'bottom', + children: (row) => , + } + + const filterList = isEmail + ? [...releaseStatusFilters, ...quarantineReasonFilters] + : releaseStatusFilters + + const simpleColumns = [ + 'ReceivedTime', + 'Subject', + 'SenderAddress', + 'Type', + 'ReleaseStatus', + 'PolicyType', + 'Expires', + 'RecipientAddress', + 'ReleasedUser', + 'Tenant', + ] + + return ( + <> + + setDialogOpen(false)} + maxWidth="lg" + fullWidth + > + + Quarantine Message + setDialogOpen(false)} + sx={{ position: 'absolute', right: 8, top: 8 }} + > + + + + + {getMessageContents.isSuccess ? ( + + ) : ( + + )} + + + setHeaderDialogOpen(false)} + maxWidth="lg" + fullWidth + > + + Message Headers - {headerRow?.Subject} + setHeaderDialogOpen(false)} + sx={{ position: 'absolute', right: 8, top: 8 }} + > + + + + + {getMessageHeaders.isSuccess ? ( + + {getMessageHeaders?.data?.Header} + + ) : ( + + )} + + + setTraceDialogOpen(false)} + maxWidth="lg" + fullWidth + > + + Message Trace - {messageSubject} + setTraceDialogOpen(false)} + sx={{ position: 'absolute', right: 8, top: 8 }} + > + + + + + {getMessageTraceDetails.isPending && ( + + {' '} + Loading message trace details... + + )} + {getMessageTraceDetails.isSuccess && ( + + getMessageTraceDetails.mutate({ + url: '/api/ListMessageTrace', + data: { + tenantFilter: traceTenant, + messageId: traceMessageId, + }, + }) + } + isFetching={getMessageTraceDetails.isPending} + /> + )} + + + + ) +} + +export default CippQuarantineTable diff --git a/src/components/CippComponents/CippReportToolbar.jsx b/src/components/CippComponents/CippReportToolbar.jsx index 30d18a6d3a66..9b635e592ca5 100644 --- a/src/components/CippComponents/CippReportToolbar.jsx +++ b/src/components/CippComponents/CippReportToolbar.jsx @@ -1,22 +1,53 @@ -import { Box, Button, Tooltip } from '@mui/material' +import { + Box, + Button, + ButtonBase, + IconButton, + List, + ListItemButton, + ListItemIcon, + ListItemText, + Tooltip, + Typography, +} from '@mui/material' +import { visuallyHidden } from '@mui/utils' import { useState, useEffect } from 'react' import { useRouter } from 'next/router' import { useForm, useWatch } from 'react-hook-form' import { useSettings } from '../../hooks/use-settings' +import { useIsMobileLayout } from '../../hooks/use-breakpoint' import { ApiGetCall } from '../../api/ApiCall.jsx' import { useQueryClient } from '@tanstack/react-query' -import { Refresh as RefreshIcon, Delete as DeleteIcon } from '@mui/icons-material' +import { + Add, + Check, + Delete as DeleteIcon, + Edit, + KeyboardArrowDown, + MoreVert, + Refresh as RefreshIcon, + Sync, +} from '@mui/icons-material' import CippFormComponent from './CippFormComponent' import { CippAddTestReportDrawer } from './CippAddTestReportDrawer' import { CippApiDialog } from './CippApiDialog' +import { CippBottomSheet } from './CippBottomSheet' +import { useSheetHandoff } from '../../hooks/use-sheet-handoff' export const CippReportToolbar = () => { const settings = useSettings() const router = useRouter() const { currentTenant } = settings const queryClient = useQueryClient() + const isMobile = useIsMobileLayout() const [deleteDialog, setDeleteDialog] = useState({ open: false }) const [refreshDialog, setRefreshDialog] = useState({ open: false }) + const [actionSheetOpen, setActionSheetOpen] = useState(false) + const [suiteSheetOpen, setSuiteSheetOpen] = useState(false) + // Every row here opens a drawer or dialog — let the sheet close first + const actionSheet = useSheetHandoff(() => setActionSheetOpen(false)) + const [createDrawerOpen, setCreateDrawerOpen] = useState(false) + const [editDrawerOpen, setEditDrawerOpen] = useState(false) const defaultReportId = settings.UserSpecificSettings?.defaultTestSuite?.value || @@ -73,105 +104,276 @@ export const CippReportToolbar = () => { const isBuiltIn = selectedReportObject?.source === 'file' const selectedCustomReport = selectedReportObject?.type === 'custom' ? selectedReportObject : null + const openRefreshDialog = () => { + setRefreshDialog({ + open: true, + handleClose: () => setRefreshDialog({ open: false }), + }) + } + + const openDeleteDialog = () => { + const report = reports.find((r) => r.id === selectedReport) + if (report) { + setDeleteDialog({ + open: true, + handleClose: () => setDeleteDialog({ open: false }), + row: { ReportId: selectedReport, name: report.name }, + }) + } + } + + const suiteSelector = (withRefreshAction) => ( + ({ + label: r.name, + value: r.id, + description: r.description, + }))} + placeholder="Choose a test suite" + {...(withRefreshAction && { + customAction: { + position: 'outside', + icon: , + tooltip: 'Refresh test suites', + onClick: handleRefresh, + }, + })} + isFetching={reportsApi.isFetching} + /> + ) + return ( <> - - - ({ - label: r.name, - value: r.id, - description: r.description, - }))} - placeholder="Choose a test suite" - customAction={{ - position: 'outside', - icon: , - tooltip: 'Refresh test suites', - onClick: handleRefresh, + {isMobile ? ( + // Trigger + kebab only; picking a suite and the suite actions are both bottom + // sheets — the house pick-one pattern, so no keyboard is summoned for a list nobody + // types into. The overlays the actions open are mounted below, outside the sheet. + + setSuiteSheetOpen(true)} + aria-haspopup="dialog" + sx={{ + flex: 1, + minWidth: 0, + height: 44, + display: 'flex', + alignItems: 'center', + gap: 0.75, + px: 1.5, + borderRadius: 1, + border: 1, + borderColor: 'divider', + bgcolor: 'background.paper', + textAlign: 'left', }} - isFetching={reportsApi.isFetching} - /> + > + + {selectedReportObject?.name ?? 'Select a test suite'} + + + switch test suite + + + + setActionSheetOpen(true)} + sx={{ minWidth: 44, minHeight: 44 }} + > + + - - - - - - - - + {/* minWidth: 0 lets the selector shrink when the row is tight instead of pushing + the trailing buttons off-screen. Layout is unchanged at widths where it fit. */} + {suiteSelector(true)} + + + + + + + + + + + + + + )} + + {isMobile && ( + setSuiteSheetOpen(false)} + title="Test suite" > - - - - - + + {reports.map((report) => { + const selected = report.id === selectedReport + return ( + { + setSuiteSheetOpen(false) + if (!selected) { + // Same write the autocomplete made — the routing effect owns the push + formControl.setValue('reportId', { value: report.id, label: report.name }) + } + }} + > + + {selected && } + + ) + })} + + + )} + {isMobile && ( + <> + + + actionSheet.run(() => setCreateDrawerOpen(true))} + > + + + + + + actionSheet.run(() => openRefreshDialog())} + > + + + + + + actionSheet.run(() => setEditDrawerOpen(true))} + > + + + + + + actionSheet.run(() => openDeleteDialog())} + > + + + + + + actionSheet.run(() => handleRefresh())} + > + + + + + + + + setCreateDrawerOpen(false)} + /> + setEditDrawerOpen(false)} + /> + + )} - + {categories .filter((c) => selectedCategories[c.key]) .map((c) => ( diff --git a/src/components/CippComponents/CippSankey.jsx b/src/components/CippComponents/CippSankey.jsx index f22f091e80cc..44d46736a770 100644 --- a/src/components/CippComponents/CippSankey.jsx +++ b/src/components/CippComponents/CippSankey.jsx @@ -1,9 +1,36 @@ +import { useMemo } from "react"; import { ResponsiveSankey } from "@nivo/sankey"; -import { useSettings } from "../../hooks/use-settings"; +import { Box, ButtonBase, Typography, useTheme } from "@mui/material"; +import { useIsMobileLayout } from "../../hooks/use-breakpoint"; + +// A node's weight: what flows in, or out if nothing flows in (the leftmost column). +const nodeTotals = (data) => { + const incoming = new Map(); + const outgoing = new Map(); + (data?.links ?? []).forEach((link) => { + incoming.set(link.target, (incoming.get(link.target) ?? 0) + (link.value ?? 0)); + outgoing.set(link.source, (outgoing.get(link.source) ?? 0) + (link.value ?? 0)); + }); + return (data?.nodes ?? []).map((node) => ({ + ...node, + total: incoming.get(node.id) ?? outgoing.get(node.id) ?? 0, + })); +}; export const CippSankey = ({ data, onNodeClick, onLinkClick }) => { - const settings = useSettings(); - const isDark = settings.currentTheme?.value === "dark"; + // The painted palette, not the theme *setting*: when the setting is "browser" the app + // resolves dark/light from the OS preference, so checking the setting for "dark" said + // light while the page was dark — and a "multiply" blend over a dark card composites the + // link ribbons to black. + const isDark = useTheme().palette.mode === "dark"; + // A sankey is three columns of nodes plus their labels. At desktop widths the labels sit + // horizontally inside an 18px-thick node and still read. On a ~350px card they cannot: a + // node carrying a handful of users is a couple of pixels tall, and its label — rotated or + // not — is longer than the node it belongs to, so the small ones pile on top of each other + // into an unreadable smear. Below md the chart drops its labels and names the nodes in a + // legend underneath, where there is room to read them and a real tap target per node. + const isMobile = useIsMobileLayout(); + const legend = useMemo(() => (isMobile ? nodeTotals(data) : []), [isMobile, data]); const theme = { tooltip: { @@ -19,7 +46,7 @@ export const CippSankey = ({ data, onNodeClick, onLinkClick }) => { }, labels: { text: { - fontSize: 12, + fontSize: isMobile ? 9 : 12, }, }, }; @@ -30,47 +57,118 @@ export const CippSankey = ({ data, onNodeClick, onLinkClick }) => { style={{ height: "100%", width: "100%", + display: "flex", + flexDirection: "column", + minHeight: 0, cursor: onNodeClick || onLinkClick ? "pointer" : "default", }} > - node.nodeColor} - label={(node) => node.label ?? node.id} - nodeOpacity={1} - nodeHoverOthersOpacity={0.35} - nodeThickness={18} - nodeSpacing={24} - nodeBorderWidth={0} - nodeBorderColor={{ - from: "color", - modifiers: [["darker", 0.8]], - }} - nodeBorderRadius={3} - linkOpacity={0.5} - linkHoverOthersOpacity={0.1} - linkContract={3} - linkBlendMode={isDark ? "lighten" : "multiply"} - enableLinkGradient={true} - labelPosition="inside" - labelOrientation="horizontal" - labelPadding={16} - labelTextColor={isDark ? "#ffffff" : "#000000"} - sort="input" - legends={[]} - valueFormat={(value) => `${value}`} - isInteractive={true} - onClick={(node, event) => { - if (onNodeClick && node.id) { - onNodeClick(node); - } else if (onLinkClick && node.source) { - onLinkClick(node); +
    + + align="justify" + colors={(node) => node.nodeColor} + label={(node) => node.label ?? node.id} + nodeOpacity={1} + nodeHoverOthersOpacity={0.35} + nodeThickness={isMobile ? 10 : 18} + nodeSpacing={isMobile ? 12 : 24} + nodeBorderWidth={0} + nodeBorderColor={{ + from: "color", + modifiers: [["darker", 0.8]], + }} + nodeBorderRadius={3} + linkOpacity={isMobile ? 0.75 : 0.5} + linkHoverOthersOpacity={0.1} + // Contracting each end eats into the gap between node columns; on a narrow chart + // that gap is small enough that 3px a side visibly thins the ribbons. + linkContract={isMobile ? 0 : 3} + // mix-blend-mode on SVG is unreliable in mobile WebKit — combined with a gradient + // fill it can composite the ribbons to nothing, which shows as bare node bars with + // no links between them. Blend is decoration here, so mobile renders them plainly + // and leans on opacity instead. + linkBlendMode={isMobile ? "normal" : isDark ? "lighten" : "multiply"} + enableLinkGradient={!isMobile} + enableLabels={!isMobile} + labelPosition="inside" + labelOrientation={isMobile ? "vertical" : "horizontal"} + labelPadding={isMobile ? 6 : 16} + labelTextColor={isDark ? "#ffffff" : "#000000"} + sort="input" + legends={[]} + valueFormat={(value) => `${value}`} + isInteractive={true} + onClick={(node, event) => { + if (onNodeClick && node.id) { + onNodeClick(node); + } else if (onLinkClick && node.source) { + onLinkClick(node); + } + }} + /> +
    + {isMobile && legend.length > 0 && ( + + {legend.map((node) => ( + + onNodeClick?.(node)} + disabled={!onNodeClick} + sx={{ + width: "100%", + minHeight: 28, + px: 0.5, + borderRadius: 0.5, + display: "flex", + alignItems: "center", + gap: 0.75, + textAlign: "left", + justifyContent: "flex-start", + }} + > + + + {node.label ?? node.id} + + + {node.total} + + + + ))} + + )} ); }; diff --git a/src/components/CippComponents/CippSettingsSideBar.jsx b/src/components/CippComponents/CippSettingsSideBar.jsx index a2b9635953d0..61da427d6379 100644 --- a/src/components/CippComponents/CippSettingsSideBar.jsx +++ b/src/components/CippComponents/CippSettingsSideBar.jsx @@ -60,6 +60,7 @@ export const CippSettingsSideBar = (props) => { // General Settings usageLocation: formValues.usageLocation, tablePageSize: formValues.tablePageSize, + tableViewMode: formValues.tableViewMode, defaultTestSuite: formValues.defaultTestSuite, userAttributes: formValues.userAttributes, @@ -107,6 +108,7 @@ export const CippSettingsSideBar = (props) => { ClearImmutableId: formValues.offboardingDefaults?.ClearImmutableId, removeCalendarPermissions: formValues.offboardingDefaults?.removeCalendarPermissions, DisableOneDriveSharing: formValues.offboardingDefaults?.DisableOneDriveSharing, + OOO: formValues.offboardingDefaults?.OOO, postExecution: { psa: formValues.offboardingDefaults?.postExecution?.psa, email: formValues.offboardingDefaults?.postExecution?.email, diff --git a/src/components/CippComponents/CippSharePointBrowserBanner.jsx b/src/components/CippComponents/CippSharePointBrowserBanner.jsx new file mode 100644 index 000000000000..6c2b7788270c --- /dev/null +++ b/src/components/CippComponents/CippSharePointBrowserBanner.jsx @@ -0,0 +1,123 @@ +import PropTypes from 'prop-types' +import { Box, Button, Card, Skeleton, Stack, Typography } from '@mui/material' +import { Add, Edit, Security, Storage as StorageIcon } from '@mui/icons-material' +import { ActionsMenu } from '../actions-menu' + +/** + * Top chrome for the SharePoint site browser: selection title on the left, + * bulk Actions + Storage + Permissions + contextual New / Edit Site on the right. + * + * Title rules: + * - site only → "SiteName" + * - site + library → "SiteName / LibraryName" (slash subdued) + * - nothing selected → placeholder + * + * Storage: when a site context is available (site-scoped reclaim). + * Permissions: only when a site or library row is selected. + * New button: + * - root → "New Site" + * - inside a site → "New Library" + * Edit Site: when a site is selected or drilled into a site (stub). + */ +export const CippSharePointBrowserBanner = ({ + site, + library, + bulkActions = [], + selectedRows = [], + isFetching = false, + queryKeys, + atRoot = true, + showStorage = false, + onStorageClick, + showPermissions = false, + onPermissionsClick, + showEditSite = false, + onEditSiteClick, +}) => { + const siteName = site?.displayName ?? null + const libraryName = library?.displayName ?? null + const hasTitle = Boolean(siteName || libraryName) + const showActions = selectedRows.length > 0 && bulkActions.length > 0 + const newLabel = atRoot ? 'New Site' : 'New Library' + + return ( + + + + {isFetching && !hasTitle ? ( + + ) : hasTitle ? ( + <> + {siteName ?? 'Site'} + {libraryName ? ( + <> + + / + + {libraryName} + + ) : null} + + ) : ( + + Select a site + + )} + + + {showActions ? ( + 1 ? 'Bulk Actions' : 'Actions'} + actions={bulkActions} + data={selectedRows} + queryKeys={queryKeys} + /> + ) : null} + {showStorage ? ( + + ) : null} + {showPermissions ? ( + + ) : null} + + {showEditSite ? ( + + ) : null} + + + + ) +} + +CippSharePointBrowserBanner.propTypes = { + site: PropTypes.object, + library: PropTypes.object, + bulkActions: PropTypes.array, + selectedRows: PropTypes.array, + isFetching: PropTypes.bool, + queryKeys: PropTypes.oneOfType([PropTypes.string, PropTypes.array]), + atRoot: PropTypes.bool, + showStorage: PropTypes.bool, + onStorageClick: PropTypes.func, + showPermissions: PropTypes.bool, + onPermissionsClick: PropTypes.func, + showEditSite: PropTypes.bool, + onEditSiteClick: PropTypes.func, +} diff --git a/src/components/CippComponents/CippSharePointBrowserPermissions.jsx b/src/components/CippComponents/CippSharePointBrowserPermissions.jsx new file mode 100644 index 000000000000..ab3c86e7a381 --- /dev/null +++ b/src/components/CippComponents/CippSharePointBrowserPermissions.jsx @@ -0,0 +1,1637 @@ +import { useEffect, useMemo, useState } from 'react' +import PropTypes from 'prop-types' +import { + Alert, + AlertTitle, + Box, + Button, + Chip, + CircularProgress, + Dialog, + DialogContent, + DialogTitle, + Divider, + IconButton, + List, + ListItemButton, + ListItemText, + Skeleton, + Stack, + Tab, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tabs, + Tooltip, + Typography, +} from '@mui/material' +import { + Add, + Close, + DeleteOutline, + EditOutlined, + LinkOff, + Link as LinkIcon, + PersonSearch, + Refresh, + Security, +} from '@mui/icons-material' +import { useForm } from 'react-hook-form' +import { ApiGetCall } from '../../api/ApiCall' +import { CippApiDialog } from './CippApiDialog' +import CippFormComponent from './CippFormComponent' +import { useDialog } from '../../hooks/use-dialog' +import { usePermissions } from '../../hooks/use-permissions' + +const EMPTY = [] + +const optionValue = (value) => + value && typeof value === 'object' && 'value' in value ? value.value : value + +const TabPanel = ({ value, index, children }) => + value === index ? {children} : null + +TabPanel.propTypes = { + value: PropTypes.number.isRequired, + index: PropTypes.number.isRequired, + children: PropTypes.node, +} + +const SectionToolbar = ({ + title, + count, + actions = EMPTY, +}) => ( + + + {title} + {typeof count === 'number' ? : null} + + {actions.length ? ( + + {actions.map((action) => ( + + + + + + ))} + + ) : null} + +) + +SectionToolbar.propTypes = { + title: PropTypes.string.isRequired, + count: PropTypes.number, + actions: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string.isRequired, + onClick: PropTypes.func, + disabled: PropTypes.bool, + disabledTitle: PropTypes.string, + icon: PropTypes.node, + }) + ), +} + +const RowActions = ({ onEdit, onRemove, disableActions = true, disabledTitle = 'Coming soon' }) => ( + + {onEdit ? ( + + + + + + + + ) : null} + {onRemove ? ( + + + + + + + + ) : null} + +) + +RowActions.propTypes = { + onEdit: PropTypes.func, + onRemove: PropTypes.func, + disableActions: PropTypes.bool, + disabledTitle: PropTypes.string, +} + +const PrincipalChips = ({ row }) => ( + + {row.isGuest ? : null} + {row.isSiteAdmin ? : null} + {row.isSystemGroup ? : null} + {row.isSystemManaged ? : null} + +) + +PrincipalChips.propTypes = { + row: PropTypes.object.isRequired, +} + +const EmptyState = ({ message = 'None' }) => ( + + {message} + +) + +EmptyState.propTypes = { + message: PropTypes.string, +} + +const AccessTable = ({ rows = EMPTY, canWrite = false, systemGroupIds = EMPTY, onEdit, onRemove }) => { + const systemIds = useMemo(() => { + const set = new Set() + ;(Array.isArray(systemGroupIds) ? systemGroupIds : []).forEach((id) => { + if (id !== null && id !== undefined && `${id}`.length) set.add(`${id}`) + }) + return set + }, [systemGroupIds]) + + if (!rows.length) return + + return ( + + + + + Principal + Type + Permission + Email / UPN + + Actions + + + + + {rows.map((row, index) => { + const levels = + Array.isArray(row.permissionLevels) && row.permissionLevels.length + ? row.permissionLevels + : row.permissionLevel + ? [ + { + name: row.permissionLevel, + isSystemManaged: row.isSystemManaged, + roleDefinitionId: row.roleDefinitionId, + }, + ] + : [] + const onlySystem = levels.length > 0 && levels.every((level) => level.isSystemManaged) + const isSystemGroup = + Boolean(row.isSystemGroup) || + (row.principalId != null && systemIds.has(`${row.principalId}`)) + const canAct = canWrite && !onlySystem && !isSystemGroup && !!row.principalId + + return ( + + + + + {row.title || '—'} + + + + + + {row.principalType || '—'} + + + + {levels.length + ? levels.map((level) => ( + } + variant={level.isSystemManaged ? 'outlined' : 'filled'} + label={level.name || '—'} + title={ + level.isSystemManaged + ? 'System-managed (e.g. Limited Access)' + : undefined + } + /> + )) + : '—'} + + + + + {row.userPrincipalName || row.email || row.loginName || '—'} + + + + onEdit(row) : undefined} + onRemove={canAct && onRemove ? () => onRemove(row) : undefined} + /> + + + ) + })} + +
    +
    + ) +} + +AccessTable.propTypes = { + rows: PropTypes.array, + canWrite: PropTypes.bool, + systemGroupIds: PropTypes.array, + onEdit: PropTypes.func, + onRemove: PropTypes.func, +} + +const MembersTable = ({ + rows = EMPTY, + canWrite = false, + onRemoveMember, + disableRemove = false, + disableRemoveTitle = 'Remove unavailable', +}) => { + if (!rows.length) return + + return ( + + + + + Name + Type + Email / UPN + + Actions + + + + + {rows.map((row, index) => { + const canRemove = + canWrite && + !disableRemove && + typeof onRemoveMember === 'function' && + !!row.principalId + + return ( + + + + + {row.title || '—'} + + + + + + {row.principalType || '—'} + + + + {row.userPrincipalName || row.email || row.loginName || '—'} + + + + onRemoveMember(row) + : undefined + } + /> + + + ) + })} + +
    +
    + ) +} + +MembersTable.propTypes = { + rows: PropTypes.array, + canWrite: PropTypes.bool, + onRemoveMember: PropTypes.func, + disableRemove: PropTypes.bool, + disableRemoveTitle: PropTypes.string, +} + +const GraphSitePermissionsTable = ({ rows = EMPTY, canWrite = false, onRemove }) => { + if (!rows.length) { + return + } + + return ( + + + + + Principal + Type + Roles + Id + + Actions + + + + + {rows.map((row, index) => { + const canRemove = canWrite && !!row.permissionId && typeof onRemove === 'function' + return ( + + + + {row.title || '—'} + + + + + {row.identityType || '—'} + + + + + {(row.roles ?? []).length + ? row.roles.map((role) => ( + } label={role} /> + )) + : '—'} + + + + + {row.identityId || '—'} + + + + onRemove(row) : undefined} + /> + + + ) + })} + +
    +
    + ) +} + +GraphSitePermissionsTable.propTypes = { + rows: PropTypes.array, + canWrite: PropTypes.bool, + onRemove: PropTypes.func, +} + +const SITE_ROOT = '__siteRoot__' +const SITE_ROOT_OPTION = { label: 'Site root (whole site)', value: SITE_ROOT } + +/** + * Effective-access check: one user × this site/library, with every route explained. + * Lives inline in Permissions (not a stacked dialog). Reuses ListSiteUserAccess. + */ +const CheckAccessPanel = ({ + open, + tenantFilter, + siteUrl, + siteId, + defaultListId, + defaultListLabel, +}) => { + const defaultScope = useMemo(() => { + if (defaultListId) { + return { + label: defaultListLabel || 'Current library', + value: defaultListId, + } + } + return SITE_ROOT_OPTION + }, [defaultListId, defaultListLabel]) + + const formControl = useForm({ + defaultValues: { user: null, scope: defaultScope }, + }) + const selectedUser = formControl.watch('user') + const selectedScope = formControl.watch('scope') + const [query, setQuery] = useState(null) + + useEffect(() => { + if (!open) { + setQuery(null) + formControl.reset({ user: null, scope: defaultScope }) + return + } + formControl.setValue('scope', defaultScope) + }, [open, defaultScope, formControl]) + + const libraries = ApiGetCall({ + url: '/api/ListSiteLibraries', + data: { SiteId: siteId, SiteUrl: siteUrl, tenantFilter }, + queryKey: `SiteLibraries-${siteId ?? siteUrl}`, + waiting: open && !!siteUrl, + }) + + const scopeOptions = useMemo(() => { + const libs = Array.isArray(libraries.data?.Results) ? libraries.data.Results : [] + const fromApi = libs.map((library) => ({ + label: library.Title, + value: library.Id, + })) + // Keep the current library visible even if ListSiteLibraries is still loading. + if ( + defaultListId && + !fromApi.some((option) => String(option.value) === String(defaultListId)) + ) { + fromApi.unshift({ + label: defaultListLabel || 'Current library', + value: defaultListId, + }) + } + return [SITE_ROOT_OPTION, ...fromApi] + }, [libraries.data, defaultListId, defaultListLabel]) + + const access = ApiGetCall({ + url: '/api/ListSiteUserAccess', + data: query ?? {}, + queryKey: `SiteUserAccess-${siteUrl}-${query?.ListId || 'root'}-${query?.UserPrincipalName}`, + waiting: open && !!query, + }) + + const runCheck = () => { + const upn = optionValue(selectedUser) + if (!upn) return + const scopeId = optionValue(selectedScope) + setQuery({ + tenantFilter, + SiteUrl: siteUrl, + ListId: !scopeId || scopeId === SITE_ROOT ? '' : scopeId, + UserPrincipalName: upn, + }) + } + + const result = access.data?.Results + const data = typeof result === 'object' && result !== null ? result : null + const loadError = typeof result === 'string' ? result : null + const paths = Array.isArray(data?.Paths) ? data.Paths : EMPTY + const realPaths = paths.filter((path) => path.GrantsRealAccess) + const limitedOnly = paths.length > 0 && realPaths.length === 0 + + return ( + + + Pick a user to see every route that grants them access here — direct grants, SharePoint + groups, nested Entra groups, tenant-wide claims, and (when cached) sharing links. This is + the inverse of the Access tab: who can reach this place, and how. + + + + + `${user.displayName} (${user.userPrincipalName})`, + valueField: 'userPrincipalName', + showRefresh: true, + }} + /> + + + + + + + + {loadError ? {loadError} : null} + + {access.isFetching ? : null} + + {!access.isFetching && data ? ( + + + {data.HasAccess ? ( + + + {data.DisplayName} has access via {data.AccessPathCount}{' '} + {data.AccessPathCount === 1 ? 'route' : 'routes'} + + Removing one route does not remove the others — every route below has to go for + access to stop. + + ) : ( + + {data.DisplayName} has no access + {limitedOnly + ? 'The only entry found is Limited Access, which SharePoint adds so a user can traverse to a specific item. It does not let them open or list anything here.' + : 'No permission, group membership or sharing link grants this user access to this scope.'} + + )} + + {data.LibraryInherits ? ( + + This library inherits permissions from the site, so the site's permissions were + evaluated. + + ) : null} + + + + {data.IsGuest ? ( + + ) : null} + {!data.SharingLinksChecked ? ( + + ) : null} + + + {!paths.length ? ( + + ) : ( + + + + + Route + Via + Permission + Applies to + Flags + + + + {paths.map((path, index) => ( + + {path.Route || '—'} + {path.Via || '—'} + {path.PermissionLevel || '—'} + {path.AppliesTo || '—'} + + + {path.IsSystemManaged ? ( + + ) : null} + {path.GrantsRealAccess === false ? ( + + ) : null} + + + + ))} + +
    +
    + )} +
    + ) : null} +
    + ) +} + +CheckAccessPanel.propTypes = { + open: PropTypes.bool, + tenantFilter: PropTypes.string, + siteUrl: PropTypes.string, + siteId: PropTypes.string, + defaultListId: PropTypes.string, + defaultListLabel: PropTypes.string, +} + +/** + * Permissions dialog for the SharePoint site browser. + * Access / Groups / Admins / Apps / Check access. + * Sharing links are out of scope (handled elsewhere). + */ +export const CippSharePointBrowserPermissions = ({ + open = false, + onClose, + item, + tenantFilter, + siteUrl: siteUrlProp, + siteId: siteIdProp, +}) => { + const [tab, setTab] = useState(0) + const [selectedGroupKey, setSelectedGroupKey] = useState(null) + const { checkPermissions } = usePermissions() + const canWrite = checkPermissions(['Sharepoint.Site.ReadWrite']) + + const addUserDialog = useDialog() + const addGroupDialog = useDialog() + const removeMemberDialog = useDialog() + const grantUserDialog = useDialog() + const grantGroupDialog = useDialog() + const replaceAccessDialog = useDialog() + const removeAccessDialog = useDialog() + const addAdminDialog = useDialog() + const removeAdminDialog = useDialog() + const breakInheritanceDialog = useDialog() + const restoreInheritanceDialog = useDialog() + const removeGraphPermissionDialog = useDialog() + + const isLibrary = item?.type === 'library' + const siteUrl = siteUrlProp ?? (isLibrary ? null : item?.webUrl) + const siteId = siteIdProp ?? (isLibrary ? null : item?.id) + const listId = isLibrary ? item?.id : null + const effectiveSiteUrl = siteUrl ?? item?.webUrl + const effectiveSiteId = siteId ?? item?.siteId ?? item?.id + const permissionsQueryKey = `ListSiteBrowserPermissions-${tenantFilter}-${effectiveSiteUrl}-${listId || 'site'}` + + const api = ApiGetCall({ + url: '/api/ListSiteBrowserPermissions', + data: { + tenantFilter, + SiteUrl: effectiveSiteUrl, + SiteId: effectiveSiteId, + ...(listId ? { ListId: listId } : {}), + }, + queryKey: permissionsQueryKey, + waiting: open && !!tenantFilter && !!effectiveSiteUrl, + }) + + const roleDefinitions = ApiGetCall({ + url: '/api/ListSiteRoleDefinitions', + data: { SiteUrl: effectiveSiteUrl, tenantFilter }, + queryKey: `SiteRoleDefinitions-${effectiveSiteUrl}`, + waiting: open && !!tenantFilter && !!effectiveSiteUrl, + }) + + const result = api.data?.Results + const loadError = + typeof result === 'string' + ? result + : api.isError + ? (api.error?.message ?? 'Failed to load permissions.') + : null + const data = typeof result === 'object' && result !== null ? result : null + + const titleName = data?.target?.title || item?.displayName || item?.name || 'Permissions' + const targetType = data?.target?.type || (isLibrary ? 'library' : 'site') + const inherits = Boolean(data?.target?.inheritsFromSite) + const hasUnique = Boolean(data?.target?.hasUniqueRoleAssignments) + const canMutateAccess = canWrite && !(targetType === 'library' && inherits) + const writeDisabledTitle = !canWrite + ? 'Requires SharePoint write permission' + : inherits + ? 'Break inheritance to change library access' + : 'Unavailable' + + const levelOptions = useMemo(() => { + const definitions = Array.isArray(roleDefinitions.data?.Results) + ? roleDefinitions.data.Results + : [] + return definitions.map((definition) => ({ + label: definition.IsCustom ? `${definition.Name} (custom)` : definition.Name, + value: definition.Id, + })) + }, [roleDefinitions.data]) + + const scopePayload = { + tenantFilter, + SiteUrl: effectiveSiteUrl, + ListId: targetType === 'library' ? listId : '', + LibraryName: targetType === 'library' ? titleName : '', + } + + const accessRows = useMemo(() => { + if (!data) return [] + if (targetType === 'library' && !inherits) { + return data.libraryRoleAssignments ?? [] + } + return data.webRoleAssignments ?? [] + }, [data, targetType, inherits]) + + const systemGroupIds = useMemo( + () => + (data?.associatedGroups ?? []) + .map((group) => group.groupId) + .filter((id) => id !== null && id !== undefined && `${id}`.length), + [data] + ) + const groupList = useMemo(() => { + if (!data) return [] + const associated = (data.associatedGroups ?? []).map((group) => ({ + key: `assoc-${group.role}`, + kind: 'associated', + label: group.role, + subtitle: group.title || '', + memberCount: group.memberCount ?? group.members?.length ?? 0, + members: group.members ?? [], + groupId: group.groupId, + isSystemGroup: true, + })) + const associatedIds = new Set(associated.map((g) => g.groupId).filter(Boolean)) + const custom = (data.sharePointGroups ?? []) + .filter((group) => !associatedIds.has(group.groupId)) + .map((group) => ({ + key: `sp-${group.groupId}`, + kind: 'sharepoint', + label: group.title || group.loginName || group.groupId, + subtitle: group.description || 'SharePoint group', + memberCount: group.memberCount ?? group.members?.length ?? 0, + members: group.members ?? [], + groupId: group.groupId, + isSystemGroup: Boolean(group.isSystemGroup), + })) + return [...associated, ...custom] + }, [data]) + + const activeGroup = + groupList.find((group) => group.key === selectedGroupKey) || groupList[0] || null + const canNestIntoActiveGroup = canWrite && !!activeGroup?.groupId + + const handleClose = () => { + setTab(0) + setSelectedGroupKey(null) + onClose?.() + } + + const removeMember = removeMemberDialog.data + const accessRow = replaceAccessDialog.data || removeAccessDialog.data + const adminRow = removeAdminDialog.data + const graphPermissionRow = removeGraphPermissionDialog.data + const graphSitePermissions = data?.graphSitePermissions ?? [] + const accessScopeLabel = + targetType === 'library' && !inherits ? `library ${titleName}` : 'the site' + + return ( + + + + + Permissions — {titleName} + + + + {inherits ? : null} + {hasUnique && targetType === 'library' ? ( + + ) : null} + {data?.collectedAt ? ( + + Collected {new Date(data.collectedAt).toLocaleString()} + + ) : null} + + + + + + api.refetch()} + disabled={!effectiveSiteUrl || api.isFetching} + > + + + + + + + + + + + {!effectiveSiteUrl ? ( + No site URL available for this selection. + ) : api.isFetching && !data ? ( + + + + ) : loadError ? ( + {loadError} + ) : data ? ( + + {data.errors?.length ? ( + + Some sections failed to load ({data.errors.length}). Showing what was collected. + + ) : null} + + {targetType === 'library' && inherits ? ( + } + disabled={!canWrite} + onClick={() => breakInheritanceDialog.handleOpen()} + > + Stop inheriting + + } + > + This library inherits permissions from the site. Showing site role assignments; + stop inheriting to manage library-specific access. + + ) : null} + + {targetType === 'library' && hasUnique && !inherits ? ( + } + disabled={!canWrite} + onClick={() => restoreInheritanceDialog.handleOpen()} + > + Restore inheritance + + } + > + This library has unique permissions. Restoring inheritance discards them and + follows the site again. + + ) : null} + + setTab(next)} + variant="scrollable" + allowScrollButtonsMobile + > + + + + + + + + + + grantUserDialog.handleOpen(), + disabled: !canMutateAccess, + disabledTitle: writeDisabledTitle, + }, + { + label: 'Grant group', + onClick: () => grantGroupDialog.handleOpen(), + disabled: !canMutateAccess, + disabledTitle: writeDisabledTitle, + }, + ]} + /> + replaceAccessDialog.handleOpen(row)} + onRemove={(row) => removeAccessDialog.handleOpen(row)} + /> + + + + {!groupList.length ? ( + + ) : ( + + + + Groups + + + {groupList.map((group) => ( + setSelectedGroupKey(group.key)} + > + + + ))} + + + + + addUserDialog.handleOpen(), + disabled: !canNestIntoActiveGroup, + disabledTitle: !canWrite + ? 'Requires SharePoint write permission' + : !activeGroup?.groupId + ? 'Select a SharePoint group' + : 'Unavailable', + }, + { + label: 'Add group', + onClick: () => addGroupDialog.handleOpen(), + disabled: !canNestIntoActiveGroup, + disabledTitle: !canWrite + ? 'Requires SharePoint write permission' + : !activeGroup?.groupId + ? 'Select a SharePoint group' + : 'Unavailable', + }, + ]} + /> + {activeGroup?.subtitle ? ( + + {activeGroup.subtitle} + {activeGroup.kind === 'associated' ? ' · Associated group' : ''} + + ) : null} + removeMemberDialog.handleOpen(row)} + /> + + + )} + + + + addAdminDialog.handleOpen(), + disabled: !canWrite, + disabledTitle: 'Requires SharePoint write permission', + }, + ]} + /> + removeAdminDialog.handleOpen(row)} + /> + + Site collection admins are separate from Owners group membership. + + + + + + removeGraphPermissionDialog.handleOpen(row)} + /> + + Site-scoped Graph grants (typically Sites.Selected app access). Separate from + SharePoint role assignments and sharing links. + + + + + + + + ) : null} + + + ({ + ...scopePayload, + Action: 'GrantAccess', + RoleDefinitionId: optionValue(formData.RoleDefinitionId), + Users: formData.Users ?? [], + }), + multiPost: false, + }} + row={item ?? {}} + > + {({ formHook }) => ( + <> + `${user.displayName} (${user.userPrincipalName})`, + valueField: 'userPrincipalName', + addedField: { id: 'id' }, + showRefresh: true, + }} + /> + + + )} + + + ({ + ...scopePayload, + Action: 'GrantAccess', + RoleDefinitionId: optionValue(formData.RoleDefinitionId), + Groups: formData.Groups ?? [], + }), + multiPost: false, + }} + row={item ?? {}} + > + {({ formHook }) => ( + <> + + group.mail ? `${group.displayName} (${group.mail})` : group.displayName, + valueField: 'id', + addedField: { + securityEnabled: 'securityEnabled', + groupTypes: 'groupTypes', + }, + showRefresh: true, + }} + /> + + + )} + + + ({ + ...scopePayload, + Action: 'ReplaceAccess', + PrincipalId: accessRow?.principalId, + PrincipalName: accessRow?.title, + RoleDefinitionId: optionValue(formData.RoleDefinitionId), + }), + multiPost: false, + }} + row={accessRow ?? {}} + > + {({ formHook }) => ( + + )} + + + ({ + ...scopePayload, + Action: 'RemoveAccess', + PrincipalId: accessRow?.principalId, + PrincipalName: accessRow?.title, + }), + multiPost: false, + }} + row={accessRow ?? {}} + /> + + ({ + tenantFilter, + SiteUrl: effectiveSiteUrl, + Action: 'AddGroupMember', + GroupId: activeGroup?.groupId, + GroupName: activeGroup?.subtitle || activeGroup?.label, + Users: formData.Users ?? [], + }), + multiPost: false, + }} + row={activeGroup ?? {}} + > + {({ formHook }) => ( + `${user.displayName} (${user.userPrincipalName})`, + valueField: 'userPrincipalName', + addedField: { id: 'id' }, + showRefresh: true, + }} + /> + )} + + + ({ + tenantFilter, + SiteUrl: effectiveSiteUrl, + Action: 'AddGroupMember', + GroupId: activeGroup?.groupId, + GroupName: activeGroup?.subtitle || activeGroup?.label, + Groups: formData.Groups ?? [], + }), + multiPost: false, + }} + row={activeGroup ?? {}} + > + {({ formHook }) => ( + + group.mail ? `${group.displayName} (${group.mail})` : group.displayName, + valueField: 'id', + addedField: { + securityEnabled: 'securityEnabled', + groupTypes: 'groupTypes', + }, + showRefresh: true, + }} + /> + )} + + + ({ + tenantFilter, + SiteUrl: effectiveSiteUrl, + Action: 'RemoveGroupMember', + GroupId: activeGroup?.groupId, + GroupName: activeGroup?.subtitle || activeGroup?.label, + PrincipalId: removeMember?.principalId, + PrincipalName: removeMember?.title, + }), + multiPost: false, + }} + row={removeMember ?? {}} + /> + + ({ + tenantFilter, + SiteUrl: effectiveSiteUrl, + Action: 'AddSiteAdmin', + Users: formData.Users ?? [], + }), + multiPost: false, + }} + row={item ?? {}} + > + {({ formHook }) => ( + `${user.displayName} (${user.userPrincipalName})`, + valueField: 'userPrincipalName', + addedField: { id: 'id' }, + showRefresh: true, + }} + /> + )} + + + ({ + tenantFilter, + SiteUrl: effectiveSiteUrl, + Action: 'RemoveSiteAdmin', + Users: [ + { + value: adminRow?.userPrincipalName || adminRow?.email || adminRow?.title, + label: adminRow?.title, + }, + ], + PrincipalName: adminRow?.title, + userPrincipalName: adminRow?.userPrincipalName, + }), + multiPost: false, + }} + row={adminRow ?? {}} + /> + + ({ + ...scopePayload, + Action: 'BreakInheritance', + CopyRoleAssignments: formData.CopyRoleAssignments !== false, + ClearSubscopes: formData.ClearSubscopes === true, + }), + multiPost: false, + }} + row={item ?? {}} + > + {({ formHook }) => ( + <> + + + Turn this off to start from an empty permission set. Only site collection admins can + reach the library until permissions are granted. + + + + )} + + + ({ + ...scopePayload, + Action: 'RestoreInheritance', + }), + multiPost: false, + }} + row={item ?? {}} + /> + + ({ + tenantFilter, + SiteUrl: effectiveSiteUrl, + SiteId: data?.target?.siteId || effectiveSiteId, + Action: 'RemoveGraphSitePermission', + PermissionId: graphPermissionRow?.permissionId, + PrincipalName: graphPermissionRow?.title || graphPermissionRow?.identityId, + }), + multiPost: false, + }} + row={graphPermissionRow ?? {}} + /> + + ) +} + +CippSharePointBrowserPermissions.propTypes = { + open: PropTypes.bool, + onClose: PropTypes.func, + item: PropTypes.object, + tenantFilter: PropTypes.string, + siteUrl: PropTypes.string, + siteId: PropTypes.string, +} diff --git a/src/components/CippComponents/CippSharePointBrowserProperties.jsx b/src/components/CippComponents/CippSharePointBrowserProperties.jsx new file mode 100644 index 000000000000..128652107ff5 --- /dev/null +++ b/src/components/CippComponents/CippSharePointBrowserProperties.jsx @@ -0,0 +1,184 @@ +import { useEffect } from 'react' +import PropTypes from 'prop-types' +import { Card, CardHeader, Typography } from '@mui/material' +import { CippPropertyList } from './CippPropertyList' +import { CippCopyToClipBoard } from './CippCopyToClipboard' +import { ApiPostCall } from '../../api/ApiCall' + +const isSiteLike = (item) => item && (item.type === 'site' || item.canOpen) + +const formatVersionPolicy = (props) => { + if (!props || typeof props !== 'object') return null + if (props.InheritVersionPolicyFromTenant) { + return 'Tenant default' + } + const major = + props.MajorVersionLimit === null || props.MajorVersionLimit === undefined + ? null + : Number(props.MajorVersionLimit) + const days = + props.ExpireVersionsAfterDays === null || props.ExpireVersionsAfterDays === undefined + ? null + : Number(props.ExpireVersionsAfterDays) + + if (props.EnableAutoExpirationVersionTrim) { + const parts = ['Auto trim'] + if (major !== null && !Number.isNaN(major) && major > 0) { + parts.push(`${major.toLocaleString()} major`) + } + if (days !== null && !Number.isNaN(days) && days > 0) { + parts.push(`${days.toLocaleString()} days`) + } + return parts.join(' · ') + } + + if (major !== null && !Number.isNaN(major)) { + if (major <= 0) return 'Unlimited / not set' + const label = `${major.toLocaleString()} major versions` + if (days !== null && !Number.isNaN(days) && days > 0) { + return `${label} · expire after ${days.toLocaleString()} days` + } + return label + } + + return '—' +} + +/** + * Left-hand property panel for the selected SharePoint site or library. + * List columns cover type / name / files / size — this pane keeps IDs, URL, and site version policy. + */ +export const CippSharePointBrowserProperties = ({ + item, + tenantFilter, + isFetching = false, + emptyMessage = 'Select an item to view details.', +}) => { + const siteUrl = isSiteLike(item) ? item.webUrl : null + const siteId = isSiteLike(item) ? item.id : null + const sitePropsApi = ApiPostCall({}) + + useEffect(() => { + if (!tenantFilter || (!siteUrl && !siteId)) return + sitePropsApi.mutate({ + url: '/api/ExecSiteBrowserActions', + data: { + Action: 'GetSiteProperties', + tenantFilter, + SiteUrl: siteUrl, + SiteId: siteId, + }, + }) + // refetch when the selected site changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tenantFilter, siteUrl, siteId]) + + const rawSiteProps = sitePropsApi.data?.data?.Results + const normalizedSiteUrl = siteUrl ? siteUrl.replace(/\/+$/, '') : null + const siteAdminProps = + typeof rawSiteProps === 'object' && + rawSiteProps !== null && + !Array.isArray(rawSiteProps) && + (!normalizedSiteUrl || + !rawSiteProps.Url || + String(rawSiteProps.Url).replace(/\/+$/, '') === normalizedSiteUrl) + ? rawSiteProps + : null + const versionsLabel = formatVersionPolicy(siteAdminProps) + const versionsFetching = Boolean( + (siteUrl || siteId) && (sitePropsApi.isPending || (!siteAdminProps && !sitePropsApi.isError)) + ) + + const propertyItems = (() => { + if (!item) return [] + + if (isSiteLike(item)) { + return [ + { + label: 'Description', + value: item.description?.trim() ? item.description : '—', + }, + { + label: 'Versions', + value: versionsFetching ? '' : versionsLabel || '—', + }, + { + label: 'Site ID', + value: item.siteId ? : '—', + }, + { + label: 'Graph ID', + value: item.id ? : '—', + }, + { + label: 'Web ID', + value: item.webId ? : '—', + }, + { + label: 'URL', + value: item.webUrl ? : '—', + }, + ] + } + + return [ + { label: 'Template', value: item.template || '—' }, + { + label: 'List ID', + value: item.id ? : '—', + }, + { + label: 'Site ID', + value: item.siteId ? : '—', + }, + { + label: 'URL', + value: item.webUrl ? : '—', + }, + ] + })() + + return ( + + + {!item && !isFetching ? ( + + {emptyMessage} + + ) : ( + + )} + + ) +} + +CippSharePointBrowserProperties.propTypes = { + item: PropTypes.object, + tenantFilter: PropTypes.string, + isFetching: PropTypes.bool, + emptyMessage: PropTypes.string, +} diff --git a/src/components/CippComponents/CippSharePointBrowserStorage.jsx b/src/components/CippComponents/CippSharePointBrowserStorage.jsx new file mode 100644 index 000000000000..a054dd438c64 --- /dev/null +++ b/src/components/CippComponents/CippSharePointBrowserStorage.jsx @@ -0,0 +1,747 @@ +import { useEffect, useMemo, useState } from 'react' +import PropTypes from 'prop-types' +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Dialog, + DialogContent, + DialogTitle, + Divider, + IconButton, + LinearProgress, + Stack, + Tab, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tabs, + Tooltip, + Typography, +} from '@mui/material' +import { + CleaningServices, + Close, + Refresh, + RestoreFromTrash, + Storage as StorageIcon, +} from '@mui/icons-material' +import { CippDataTable } from '../CippTable/CippDataTable' +import { CippApiDialog } from './CippApiDialog' +import CippFormComponent from './CippFormComponent' +import { CippFormCondition } from './CippFormCondition' +import { CippPropertyList } from './CippPropertyList' +import { ApiGetCall, ApiPostCall } from '../../api/ApiCall' +import { useDialog } from '../../hooks/use-dialog' +import { usePermissions } from '../../hooks/use-permissions' + +const optionValue = (value) => + value && typeof value === 'object' && 'value' in value ? value.value : value + +const TabPanel = ({ value, index, children }) => + value === index ? {children} : null + +TabPanel.propTypes = { + value: PropTypes.number.isRequired, + index: PropTypes.number.isRequired, + children: PropTypes.node, +} + +const VERSION_CLEANUP_LABELS = { + Status: 'Status', + BatchDeleteMode: 'Cleanup Mode', + RequestTimeInUTC: 'Requested (UTC)', + LastProcessTimeInUTC: 'Last Processed (UTC)', + CompleteTimeInUTC: 'Completed (UTC)', + ListsProcessed: 'Lists Processed', + ListsUpdated: 'Lists Updated', + ListsFailed: 'Lists Failed', + FilesProcessed: 'Files Processed', + VersionsProcessed: 'Versions Processed', + VersionsDeleted: 'Versions Deleted', + VersionsFailed: 'Versions Failed', + StorageReleased: 'Storage Released (bytes)', + ErrorMessage: 'Error Message', + WorkItemId: 'Work Item ID', + Message: 'Message', +} +const VERSION_CLEANUP_FIELDS = Object.keys(VERSION_CLEANUP_LABELS) +const TOP_LIBRARIES = 8 + +const formatBytes = (bytes) => { + const num = Number(bytes) + if (bytes === null || bytes === undefined || bytes === '' || Number.isNaN(num)) return null + if (num < 1024) return `${num} B` + const gb = num / (1024 * 1024 * 1024) + if (gb >= 0.01) return `${gb.toLocaleString(undefined, { maximumFractionDigits: 2 })} GB` + const mb = num / (1024 * 1024) + return `${mb.toLocaleString(undefined, { maximumFractionDigits: 2 })} MB` +} + +const toBytesFromMb = (mb) => { + if (mb === null || mb === undefined || mb === '') return null + const num = Number(mb) + if (Number.isNaN(num)) return null + return num * 1024 * 1024 +} + +const formatVersionPolicy = (props) => { + if (!props || typeof props !== 'object') return null + if (props.InheritVersionPolicyFromTenant) return 'Tenant default' + const major = + props.MajorVersionLimit === null || props.MajorVersionLimit === undefined + ? null + : Number(props.MajorVersionLimit) + const days = + props.ExpireVersionsAfterDays === null || props.ExpireVersionsAfterDays === undefined + ? null + : Number(props.ExpireVersionsAfterDays) + + if (props.EnableAutoExpirationVersionTrim) { + const parts = ['Auto trim'] + if (major !== null && !Number.isNaN(major) && major > 0) { + parts.push(`${major.toLocaleString()} major`) + } + if (days !== null && !Number.isNaN(days) && days > 0) { + parts.push(`${days.toLocaleString()} days`) + } + return parts.join(' · ') + } + + if (major !== null && !Number.isNaN(major)) { + if (major <= 0) return 'Unlimited / not set' + const label = `${major.toLocaleString()} major versions` + if (days !== null && !Number.isNaN(days) && days > 0) { + return `${label} · expire after ${days.toLocaleString()} days` + } + return label + } + return null +} + +const jobStatusChip = (progress) => { + if (!progress || typeof progress === 'string') { + return { label: 'No job', color: 'default' } + } + if (progress.Status === 'NoRequestFound' || progress.Status === 'NoJob') { + return { label: 'No job', color: 'default' } + } + const status = String(progress.Status ?? '').toLowerCase() + if (!status) return { label: 'Unknown', color: 'default' } + if (status.includes('complete') || status.includes('success')) { + return { label: progress.Status, color: 'success' } + } + if (status.includes('fail') || status.includes('error')) { + return { label: progress.Status, color: 'error' } + } + if (status.includes('run') || status.includes('progress') || status.includes('pending')) { + return { label: progress.Status, color: 'warning' } + } + return { label: progress.Status, color: 'info' } +} + +const VersionCleanupFields = ({ formHook }) => ( + <> + + + + + + + + + +) + +VersionCleanupFields.propTypes = { + formHook: PropTypes.object.isRequired, +} + +/** + * Site-scoped Storage sheet for cleanup. + * Overview (cheap live): used/quota, version policy, top libraries. + * Recycle / Versions tabs: cleanup actions — no file-level scans. + */ +export const CippSharePointBrowserStorage = ({ + open = false, + onClose, + item, + tenantFilter, +}) => { + const [tab, setTab] = useState(0) + const { checkPermissions } = usePermissions() + const canWriteSite = checkPermissions(['Sharepoint.Site.ReadWrite']) + const canReadRecycleBin = checkPermissions([ + 'Sharepoint.SiteRecycleBin.Read', + 'Sharepoint.SiteRecycleBin.ReadWrite', + ]) + const canRestore = checkPermissions(['Sharepoint.SiteRecycleBin.ReadWrite']) + const startCleanupDialog = useDialog() + + const siteUrl = item?.webUrl + const siteId = item?.id + const siteName = item?.displayName || item?.name || 'Site' + const tenant = item?.Tenant ?? tenantFilter + const sitePropsApi = ApiPostCall({}) + const jobStatusApi = ApiPostCall({}) + + const librariesApi = ApiGetCall({ + url: '/api/ListSiteBrowser', + data: { + tenantFilter: tenant, + SiteId: siteId, + SiteUrl: siteUrl, + }, + queryKey: `SiteBrowserStorageLibs-${tenant}-${siteId || siteUrl}`, + waiting: open && !!tenant && !!(siteId || siteUrl), + }) + + const fetchSiteProps = () => { + if (!tenant || (!siteUrl && !siteId)) return + sitePropsApi.mutate({ + url: '/api/ExecSiteBrowserActions', + data: { + Action: 'GetSiteProperties', + tenantFilter: tenant, + SiteUrl: siteUrl, + SiteId: siteId, + }, + }) + } + + const fetchJobStatus = () => { + if (!tenant || (!siteUrl && !siteId)) return + jobStatusApi.mutate({ + url: '/api/ExecSiteBrowserActions', + data: { + Action: 'GetVersionCleanupStatus', + tenantFilter: tenant, + SiteUrl: siteUrl, + SiteId: siteId, + }, + }) + } + + const refreshAll = () => { + fetchSiteProps() + librariesApi.refetch?.() + if (tab === 2) fetchJobStatus() + } + + useEffect(() => { + if (!open) return + setTab(0) + fetchSiteProps() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, siteUrl, siteId, tenant]) + + useEffect(() => { + if (!open || tab !== 2) return + fetchJobStatus() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, tab, siteUrl, siteId, tenant]) + + const siteProps = + typeof sitePropsApi.data?.data?.Results === 'object' && + sitePropsApi.data?.data?.Results !== null && + !Array.isArray(sitePropsApi.data?.data?.Results) + ? sitePropsApi.data.data.Results + : null + + const jobProgress = jobStatusApi.data?.data?.Results + const versionsLabel = formatVersionPolicy(siteProps) + const chip = useMemo(() => jobStatusChip(jobProgress), [jobProgress]) + + const usedBytes = useMemo(() => { + const fromItem = Number(item?.storageUsedInBytes) + if (!Number.isNaN(fromItem) && fromItem > 0) return fromItem + return toBytesFromMb(siteProps?.StorageUsage) + }, [item?.storageUsedInBytes, siteProps?.StorageUsage]) + + const quotaBytes = toBytesFromMb(siteProps?.StorageMaximumLevel) + const warningBytes = toBytesFromMb(siteProps?.StorageWarningLevel) + const usedLabel = formatBytes(usedBytes) || '—' + const quotaLabel = formatBytes(quotaBytes) + const usedPct = + quotaBytes && usedBytes !== null && quotaBytes > 0 + ? Math.min(100, Math.round((usedBytes / quotaBytes) * 1000) / 10) + : null + const nearWarning = + warningBytes && usedBytes !== null ? usedBytes >= warningBytes : usedPct !== null && usedPct >= 85 + const quotaBarColor = nearWarning ? 'warning' : 'primary' + + const libraryRows = useMemo(() => { + const raw = librariesApi.data?.Results + if (!Array.isArray(raw)) return [] + return [...raw] + .map((lib) => ({ + ...lib, + _bytes: Number(lib.storageUsedInBytes), + })) + .sort((a, b) => { + const aOk = !Number.isNaN(a._bytes) ? a._bytes : -1 + const bOk = !Number.isNaN(b._bytes) ? b._bytes : -1 + return bOk - aOk + }) + }, [librariesApi.data]) + + const topLibraries = libraryRows.slice(0, TOP_LIBRARIES) + const librariesMeasuredBytes = useMemo( + () => + libraryRows.reduce((sum, lib) => { + if (Number.isNaN(lib._bytes) || lib._bytes < 0) return sum + return sum + lib._bytes + }, 0), + [libraryRows] + ) + const librariesMeasuredLabel = formatBytes(librariesMeasuredBytes) + const maxLibBytes = topLibraries[0]?._bytes > 0 ? topLibraries[0]._bytes : 0 + + const glanceLoading = sitePropsApi.isPending && !siteProps + const libsLoading = librariesApi.isFetching && !libraryRows.length + + const handleClose = () => { + setTab(0) + onClose?.() + } + + const recycleBinQueryKey = `SiteBrowserRecycleBin-${siteUrl}` + + const recycleActions = [ + { + label: 'Restore Item', + type: 'POST', + icon: , + url: '/api/ExecRestoreRecycleBinItems', + data: { + Ids: 'Id', + ItemNames: 'LeafName', + SiteUrl: siteUrl, + tenantFilter: tenant, + }, + confirmText: 'Restore [LeafName] from the recycle bin?', + condition: () => canRestore, + multiPost: false, + }, + ] + + return ( + <> + + + + Storage — {siteName} + + + + + + + + + + + + + + + + {!siteUrl ? ( + No site URL available for this selection. + ) : ( + + setTab(next)} + variant="scrollable" + allowScrollButtonsMobile + > + + + + + + + + + {glanceLoading ? ( + + + + ) : ( + + + } + color={nearWarning ? 'warning' : 'default'} + label={ + quotaLabel + ? `Used ${usedLabel} / ${quotaLabel}${ + usedPct !== null ? ` (${usedPct}%)` : '' + }` + : `Used ${usedLabel}` + } + /> + + {librariesMeasuredLabel ? ( + + ) : null} + + + {quotaBytes ? ( + + + + {nearWarning + ? 'Near quota warning — reclaim recycle or trim versions before the site locks writes.' + : 'Quota usage from site properties (live).'} + + + ) : null} + + + Cleanup path: check largest libraries → Recycle bin + (1st/2nd stage) → Versions if history looks like the gap. Version bytes are + not measured live (that would scan files). + + + )} + + + + Largest libraries + {libsLoading ? : null} + + {librariesApi.isError ? ( + + Could not load library sizes. You can still use Recycle and Versions. + + ) : !libsLoading && !topLibraries.length ? ( + + No document libraries returned for this site. + + ) : ( + + + + + Library + Type + Files + + Size + + + + + {topLibraries.map((lib) => { + const pct = + maxLibBytes > 0 && !Number.isNaN(lib._bytes) && lib._bytes > 0 + ? Math.min(100, (lib._bytes / maxLibBytes) * 100) + : 0 + return ( + + + + {lib.displayName || lib.name || '—'} + + + + + {lib.siteType || '—'} + + + + + {lib.fileCount != null + ? Number(lib.fileCount).toLocaleString() + : '—'} + + + + + + {formatBytes(lib.storageUsedInBytes) || '—'} + + {pct > 0 ? ( + + ) : null} + + + + ) + })} + +
    +
    + )} + + Library size = root folder StorageMetrics (live). Site used may be higher — + recycle, versions, and other lists are not in this table. + {libraryRows.length > TOP_LIBRARIES + ? ` Showing top ${TOP_LIBRARIES} of ${libraryRows.length}.` + : ''} + +
    +
    +
    + + + {!canReadRecycleBin ? ( + + Recycle bin requires SharePoint recycle bin read permission. + + ) : ( + <> + + First and second stage together (newest first, capped by the API). Filter on + Item State. Sizes are per item — totals are not fully summed live. + + + + )} + + + + + + Version history trim + + + + + + + + + + + + + + Site policy: {versionsLabel || '—'}. A cleanup job trims existing file versions; it + does not change the policy. Use when libraries look smaller than site used and + recycle is already thin — classic version bloat. + + + {jobStatusApi.isError ? ( + + {typeof jobStatusApi.error?.response?.data?.Results === 'string' + ? jobStatusApi.error.response.data.Results + : 'Failed to load cleanup job status.'} + + ) : null} + + {jobStatusApi.isPending && !jobProgress ? ( + + + + ) : !jobProgress || + (typeof jobProgress === 'string' && !jobProgress.trim()) || + jobProgress?.Status === 'NoRequestFound' || + jobProgress?.Status === 'NoJob' ? ( + + {jobProgress?.Message || 'No cleanup job found for this site.'} + + ) : typeof jobProgress === 'string' ? ( + {jobProgress} + ) : ( + jobProgress?.[key] !== undefined && jobProgress?.[key] !== '' + ).map((key) => ({ + label: VERSION_CLEANUP_LABELS[key], + value: String(jobProgress[key]), + }))} + /> + )} + +
    + )} +
    +
    + + { + const mode = parseInt(optionValue(formData.BatchDeleteMode) ?? '2', 10) + return { + tenantFilter: tenant, + SiteUrl: siteUrl, + SiteId: siteId, + Action: 'StartVersionCleanup', + BatchDeleteMode: mode, + DeleteOlderThanDays: mode === 0 ? parseInt(formData.DeleteOlderThanDays, 10) : -1, + MajorVersionLimit: mode === 1 ? parseInt(formData.MajorVersionLimit, 10) : -1, + MajorWithMinorVersionsLimit: + mode === 1 ? parseInt(formData.MajorWithMinorVersionsLimit, 10) : -1, + } + }, + multiPost: false, + onSuccess: () => { + fetchJobStatus() + }, + }} + row={item ?? {}} + > + {({ formHook }) => } + + + ) +} + +CippSharePointBrowserStorage.propTypes = { + open: PropTypes.bool, + onClose: PropTypes.func, + item: PropTypes.object, + tenantFilter: PropTypes.string, +} diff --git a/src/components/CippComponents/CippSharePointFolderView.jsx b/src/components/CippComponents/CippSharePointFolderView.jsx new file mode 100644 index 000000000000..5c02606a3eb9 --- /dev/null +++ b/src/components/CippComponents/CippSharePointFolderView.jsx @@ -0,0 +1,918 @@ +import { useEffect, useMemo, useState } from 'react' +import PropTypes from 'prop-types' +import { + Alert, + Badge, + Box, + Breadcrumbs, + Button, + Card, + Checkbox, + Chip, + CircularProgress, + Divider, + FormControlLabel, + IconButton, + InputAdornment, + Link, + ListItemIcon, + ListItemText, + Menu, + MenuItem, + Popover, + Radio, + RadioGroup, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TableSortLabel, + TextField, + Tooltip, + Typography, +} from '@mui/material' +import { alpha } from '@mui/material/styles' +import { + ArrowUpward, + Clear, + FilterList, + Folder, + FolderOpen, + FolderShared, + MoreVert, + OpenInNew, + Search as SearchIcon, +} from '@mui/icons-material' + +const formatDate = (value) => { + if (!value) return '—' + const date = new Date(value) + if (Number.isNaN(date.getTime()) || date.getUTCFullYear() <= 1) return '—' + return date.toLocaleString(undefined, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }) +} + +const formatSizeGb = (bytes) => { + if (bytes === null || bytes === undefined || bytes === '') return null + const num = Number(bytes) + if (Number.isNaN(num)) return null + return num / (1024 * 1024 * 1024) +} + +const formatSizeMb = (bytes) => { + if (bytes === null || bytes === undefined || bytes === '') return null + const num = Number(bytes) + if (Number.isNaN(num)) return null + return num / (1024 * 1024) +} + +const formatSizeGbLabel = (bytes) => { + const gb = formatSizeGb(bytes) + if (gb === null) return '—' + return gb.toLocaleString(undefined, { maximumFractionDigits: 2 }) +} + +const formatSizeMbTooltip = (bytes) => { + const mb = formatSizeMb(bytes) + if (mb === null) return null + return `${mb.toLocaleString(undefined, { maximumFractionDigits: 2 })} MB` +} + +const RowActionsMenu = ({ item, actions = [] }) => { + const [anchorEl, setAnchorEl] = useState(null) + const open = Boolean(anchorEl) + const available = actions.filter((action) => { + if (typeof action.condition === 'function') return action.condition(item) + return true + }) + + if (!available.length) return null + + return ( + <> + { + event.stopPropagation() + setAnchorEl(event.currentTarget) + }} + > + + + setAnchorEl(null)} + onClick={(event) => event.stopPropagation()} + anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }} + transformOrigin={{ horizontal: 'right', vertical: 'top' }} + > + {available.map((action) => ( + { + setAnchorEl(null) + action.onClick?.(item) + }} + component={action.href ? 'a' : 'li'} + href={action.href?.(item)} + target={action.href ? '_blank' : undefined} + rel={action.href ? 'noopener noreferrer' : undefined} + > + {action.icon ? {action.icon} : null} + {action.label} + + ))} + + + ) +} + +RowActionsMenu.propTypes = { + item: PropTypes.object.isRequired, + actions: PropTypes.array, +} + +const formatFileCount = (value) => { + if (value === null || value === undefined || value === '') return '—' + const num = Number(value) + if (Number.isNaN(num)) return '—' + return num.toLocaleString() +} + +const COLUMNS = [ + { id: 'name', label: 'Name', align: 'left', width: undefined, defaultDir: 'asc' }, + { id: 'webUrl', label: 'URL', align: 'center', width: 72, defaultDir: 'asc' }, + { id: 'siteType', label: 'Type', align: 'left', width: '14%', defaultDir: 'asc' }, + { id: 'fileCount', label: 'Files', align: 'right', width: '10%', defaultDir: 'desc' }, + { id: 'size', label: 'Size (GB)', align: 'right', width: '10%', defaultDir: 'desc' }, + { id: 'created', label: 'Created', align: 'left', width: '16%', defaultDir: 'desc' }, +] + +const getSortValue = (item, columnId) => { + switch (columnId) { + case 'name': + return (item.displayName ?? item.name ?? '').toString().toLocaleLowerCase() + case 'webUrl': + return (item.webUrl ?? '').toString().toLocaleLowerCase() + case 'siteType': + return (item.siteType ?? '').toString().toLocaleLowerCase() + case 'fileCount': { + const num = Number(item.fileCount) + return Number.isFinite(num) ? num : null + } + case 'size': { + const num = Number(item.storageUsedInBytes) + return Number.isFinite(num) ? num : null + } + case 'created': { + const time = item.createdDateTime ? Date.parse(item.createdDateTime) : NaN + return Number.isFinite(time) ? time : null + } + default: + return null + } +} + +const compareItems = (a, b, columnId, direction) => { + const aVal = getSortValue(a, columnId) + const bVal = getSortValue(b, columnId) + const aEmpty = aVal === null || aVal === undefined || aVal === '' + const bEmpty = bVal === null || bVal === undefined || bVal === '' + + if (aEmpty && bEmpty) return 0 + if (aEmpty) return 1 + if (bEmpty) return -1 + + let result + if (typeof aVal === 'number' && typeof bVal === 'number') { + result = aVal - bVal + } else { + result = String(aVal).localeCompare(String(bVal), undefined, { sensitivity: 'base' }) + } + + return direction === 'asc' ? result : -result +} + +const itemSearchText = (item) => + [item?.displayName, item?.name, item?.webUrl, item?.siteType, item?.type] + .filter(Boolean) + .join(' ') + .toLowerCase() + +const matchesSearch = (item, query) => { + const q = query.trim().toLowerCase() + if (!q) return true + return itemSearchText(item).includes(q) +} + +const GB = 1024 * 1024 * 1024 +const SIZE_FILTERS = [ + { label: 'Any size', value: 0 }, + { label: 'Over 1 GB', value: 1 * GB }, + { label: 'Over 10 GB', value: 10 * GB }, + { label: 'Over 50 GB', value: 50 * GB }, + { label: 'Over 100 GB', value: 100 * GB }, +] + +const typeLabel = (item) => { + const label = (item?.siteType ?? '').toString().trim() + return label || 'Unknown' +} + +const matchesFilters = (item, { types, minSizeBytes }) => { + if (types.length > 0 && !types.includes(typeLabel(item))) return false + if (minSizeBytes > 0) { + const bytes = Number(item?.storageUsedInBytes) + if (!Number.isFinite(bytes) || bytes < minSizeBytes) return false + } + return true +} + +const sizeFilterLabel = (minSizeBytes) => + SIZE_FILTERS.find((option) => option.value === minSizeBytes)?.label ?? 'Any size' + +/** + * Explorer-style details list for the SharePoint site browser. + * Columns: Name, URL, Type, Files, Size (GB), Created. + * Click selects; double-click / Enter opens when canOpen is true. + */ +export const CippSharePointFolderView = ({ + items = [], + isFetching = false, + error, + path = [], + onNavigate, + onSelect, + checkedIds = [], + onCheckedChange, + onOpen, + rowActions = [], + emptyMessage = 'No items found.', +}) => { + const [sortBy, setSortBy] = useState('name') + const [sortDir, setSortDir] = useState('asc') + const [searchQuery, setSearchQuery] = useState('') + const [filterTypes, setFilterTypes] = useState([]) + const [minSizeBytes, setMinSizeBytes] = useState(0) + const [filterAnchor, setFilterAnchor] = useState(null) + + const pathKey = path.map((crumb) => crumb?.id ?? crumb?.webUrl ?? '').join('/') + useEffect(() => { + setSearchQuery('') + setFilterTypes([]) + setMinSizeBytes(0) + setFilterAnchor(null) + }, [pathKey]) + + const handleCrumbClick = (index) => { + if (!onNavigate) return + if (index < 0) { + onNavigate([]) + } else { + onNavigate(path.slice(0, index + 1)) + } + } + + const canGoUp = path.length > 0 + const handleGoUp = () => { + if (!canGoUp || !onNavigate) return + onNavigate(path.slice(0, -1)) + } + + const handleSort = (columnId) => { + const column = COLUMNS.find((col) => col.id === columnId) + if (!column) return + if (sortBy === columnId) { + setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc')) + return + } + setSortBy(columnId) + setSortDir(column.defaultDir) + } + + const availableTypes = useMemo(() => { + const counts = new Map() + for (const item of items) { + const label = typeLabel(item) + counts.set(label, (counts.get(label) ?? 0) + 1) + } + return [...counts.entries()] + .map(([label, count]) => ({ label, count })) + .sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })) + }, [items]) + + const filtersActive = filterTypes.length > 0 || minSizeBytes > 0 + const activeFilterCount = filterTypes.length + (minSizeBytes > 0 ? 1 : 0) + + const filteredItems = useMemo( + () => + items.filter( + (item) => + matchesSearch(item, searchQuery) && + matchesFilters(item, { types: filterTypes, minSizeBytes }) + ), + [items, searchQuery, filterTypes, minSizeBytes] + ) + + const sortedItems = useMemo(() => { + return [...filteredItems].sort((a, b) => compareItems(a, b, sortBy, sortDir)) + }, [filteredItems, sortBy, sortDir]) + + const checkedIdSet = useMemo(() => new Set(checkedIds), [checkedIds]) + const allChecked = + sortedItems.length > 0 && sortedItems.every((item) => checkedIdSet.has(item.id)) + const someChecked = sortedItems.some((item) => checkedIdSet.has(item.id)) + const searchActive = searchQuery.trim().length > 0 + const noMatches = + (searchActive || filtersActive) && items.length > 0 && sortedItems.length === 0 + const searchPlaceholder = canGoUp ? 'Search libraries…' : 'Search sites…' + + const clearFilters = () => { + setFilterTypes([]) + setMinSizeBytes(0) + } + + const toggleType = (label) => { + setFilterTypes((prev) => + prev.includes(label) ? prev.filter((value) => value !== label) : [...prev, label] + ) + } + + const handleToggleAll = (event) => { + event.stopPropagation() + if (!onCheckedChange) return + if (allChecked) { + onCheckedChange([]) + } else { + onCheckedChange(sortedItems.map((item) => item.id)) + } + } + + const handleToggleOne = (itemId) => { + if (!onCheckedChange) return + if (checkedIdSet.has(itemId)) { + onCheckedChange(checkedIds.filter((id) => id !== itemId)) + } else { + onCheckedChange([...checkedIds, itemId]) + } + } + + // Row click selects that row only; click again clears; Ctrl/Cmd+click toggles multi-select. + const handleRowActivate = (event, item) => { + if (!onCheckedChange) { + onSelect?.(item) + return + } + if (event.ctrlKey || event.metaKey) { + handleToggleOne(item.id) + } else if (checkedIds.length === 1 && checkedIds[0] === item.id) { + onCheckedChange([]) + } else { + onCheckedChange([item.id]) + } + onSelect?.(item) + } + + const showTable = !isFetching && (canGoUp || items.length > 0) + + return ( + + + + + handleCrumbClick(-1)} + sx={{ cursor: 'pointer' }} + > + Sites + + {path.map((crumb, index) => { + const isLast = index === path.length - 1 + if (isLast) { + return ( + + {crumb.displayName ?? crumb.name} + + ) + } + return ( + handleCrumbClick(index)} + sx={{ cursor: 'pointer' }} + > + {crumb.displayName ?? crumb.name} + + ) + })} + + + setSearchQuery(event.target.value)} + placeholder={searchPlaceholder} + aria-label={searchPlaceholder} + disabled={isFetching} + sx={{ + width: { xs: '100%', sm: 240 }, + flex: { xs: 1, sm: 'none' }, + '& .MuiOutlinedInput-root': { + height: 40, + boxSizing: 'border-box', + }, + '& .MuiInputAdornment-root': { + height: 'auto', + maxHeight: 'none', + marginTop: '0 !important', + }, + }} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: searchQuery ? ( + + setSearchQuery('')} + edge="end" + sx={{ p: 0.5 }} + > + + + + ) : null, + }} + /> + + + + setFilterAnchor(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} + transformOrigin={{ vertical: 'top', horizontal: 'right' }} + slotProps={{ paper: { sx: { width: 300, p: 2 } } }} + > + + + Filters + + + + + + Type + + {availableTypes.length === 0 ? ( + + No types in this list. + + ) : ( + + {availableTypes.map(({ label, count }) => ( + toggleType(label)} + /> + } + label={ + + {label}{' '} + + ({count}) + + + } + sx={{ mr: 0, ml: 0 }} + /> + ))} + + )} + + + + + + + Minimum size + + setMinSizeBytes(Number(event.target.value))} + > + {SIZE_FILTERS.map((option) => ( + } + label={{option.label}} + sx={{ mr: 0, ml: 0 }} + /> + ))} + + + + + + + + {filtersActive ? ( + + {filterTypes.map((label) => ( + toggleType(label)} + /> + ))} + {minSizeBytes > 0 ? ( + setMinSizeBytes(0)} + /> + ) : null} + + + ) : null} + + {error ? ( + {typeof error === 'string' ? error : 'Failed to load items.'} + ) : null} + + {isFetching ? ( + + + + ) : !showTable ? ( + + {emptyMessage} + + ) : ( + + + theme.palette.mode === 'dark' + ? theme.palette.background.default + : alpha(theme.palette.neutral[200], 0.4), + backgroundImage: 'none', + }, + }} + > + + + + + + {COLUMNS.map((column) => ( + + handleSort(column.id)} + sx={ + column.align === 'right' + ? { flexDirection: 'row-reverse', ml: 'auto' } + : column.align === 'center' + ? { mx: 'auto' } + : undefined + } + > + {column.label} + + + ))} + + + + + {canGoUp ? ( + { + if (event.key === 'Enter') handleGoUp() + }} + sx={{ cursor: 'pointer' }} + > + + + + + + .. + + + Go up + + + + + + — + + + + + — + + + + + — + + + + + — + + + + + — + + + + + ) : null} + {noMatches ? ( + + + + {searchActive && filtersActive + ? `No matches for “${searchQuery.trim()}” with the current filters.` + : searchActive + ? `No matches for “${searchQuery.trim()}”.` + : 'No items match the current filters.'} + + + + ) : null} + {sortedItems.length === 0 && canGoUp && !searchActive && !filtersActive ? ( + + + + {emptyMessage} + + + + ) : null} + {sortedItems.map((item) => { + const checked = checkedIdSet.has(item.id) + const isSite = + item.type === 'site' || item.canOpen + const Icon = isSite ? (checked ? FolderOpen : Folder) : FolderShared + + return ( + handleRowActivate(event, item)} + onDoubleClick={() => { + if (item.canOpen) onOpen?.(item) + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + if (item.canOpen) onOpen?.(item) + else handleRowActivate(event, item) + } + }} + sx={{ + cursor: 'pointer', + borderLeft: (theme) => + checked + ? `3px solid ${theme.palette.warning.main}` + : '3px solid transparent', + '&.Mui-selected': { + bgcolor: (theme) => + alpha( + theme.palette.warning.main, + theme.palette.mode === 'dark' ? 0.22 : 0.14 + ), + }, + '&.Mui-selected:hover': { + bgcolor: (theme) => + alpha( + theme.palette.warning.main, + theme.palette.mode === 'dark' ? 0.3 : 0.2 + ), + }, + }} + > + { + event.stopPropagation() + handleToggleOne(item.id) + }} + > + handleToggleOne(item.id)} + onClick={(event) => event.stopPropagation()} + color="warning" + inputProps={{ + 'aria-label': `Select ${item.displayName ?? item.name ?? 'item'}`, + }} + /> + + + + + + {item.displayName ?? item.name} + + + + event.stopPropagation()}> + {item.webUrl ? ( + + + + + + ) : ( + + — + + )} + + + + {item.siteType || '—'} + + + + + {formatFileCount(item.fileCount)} + + + + {formatSizeMbTooltip(item.storageUsedInBytes) ? ( + + + {formatSizeGbLabel(item.storageUsedInBytes)} + + + ) : ( + + — + + )} + + + + {formatDate(item.createdDateTime)} + + + event.stopPropagation()} + > + + + + ) + })} + +
    +
    + )} +
    +
    + ) +} + +CippSharePointFolderView.propTypes = { + items: PropTypes.array, + isFetching: PropTypes.bool, + error: PropTypes.any, + path: PropTypes.array, + onNavigate: PropTypes.func, + /** @deprecated Selection is driven by checkedIds; kept for optional side-effects. */ + selectedId: PropTypes.string, + onSelect: PropTypes.func, + checkedIds: PropTypes.arrayOf(PropTypes.string), + onCheckedChange: PropTypes.func, + onOpen: PropTypes.func, + rowActions: PropTypes.array, + emptyMessage: PropTypes.string, +} diff --git a/src/components/CippComponents/CippSharePointPermissionEditor.jsx b/src/components/CippComponents/CippSharePointPermissionEditor.jsx index ae4cb2e903db..477540f1868f 100644 --- a/src/components/CippComponents/CippSharePointPermissionEditor.jsx +++ b/src/components/CippComponents/CippSharePointPermissionEditor.jsx @@ -50,7 +50,7 @@ export const CippSharePointPermissionEditor = ({ validators={{ required: "A group display name is required" }} /> - + - + theme.breakpoints.down('md')) const formControls = actions.reduce((acc, action) => { if (action.form) { @@ -109,6 +113,10 @@ const CippSpeedDial = ({ } }, [speedDialOpen]) + if (mdDown) { + return null + } + return ( <> { const activeSponsors = getActiveSponsors(); -export const CippSponsor = () => { +// `compact` trims the vertical footprint for the mobile nav drawer, where this sits pinned +// below a scrolling menu and every pixel it takes is a pixel of navigation lost. +export const CippSponsor = ({ compact = false }) => { const pathname = usePathname(); const [selectedSponsor, setSelectedSponsor] = useState(() => selectRandomSponsor(activeSponsors)); const currentSettings = useSettings(); @@ -72,7 +74,12 @@ export const CippSponsor = () => { This application is sponsored by @@ -81,8 +88,8 @@ export const CippSponsor = () => { display: "flex", justifyContent: "center", alignItems: "center", - height: "55px", - mb: 1, + height: compact ? "38px" : "55px", + mb: compact ? 0.5 : 1, }} > @@ -91,9 +98,9 @@ export const CippSponsor = () => { alt={randomimg.altText} style={{ cursor: "pointer", - maxHeight: "50px", + maxHeight: compact ? "34px" : "50px", width: "auto", - maxWidth: "150px", + maxWidth: compact ? "130px" : "150px", }} onClick={() => window.open(randomimg.link)} /> diff --git a/src/components/CippComponents/CippSupportBundleDialog.jsx b/src/components/CippComponents/CippSupportBundleDialog.jsx new file mode 100644 index 000000000000..e90efc21796f --- /dev/null +++ b/src/components/CippComponents/CippSupportBundleDialog.jsx @@ -0,0 +1,345 @@ +import { useEffect, useRef, useState } from 'react' +import { + Alert, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + FormControlLabel, + Stack, + Switch, + Typography, +} from '@mui/material' +import { + Download, + FiberManualRecord, + PlayArrow, + Stop, +} from '@mui/icons-material' +import { useQueryClient } from '@tanstack/react-query' +import { useSettings } from '../../hooks/use-settings' +import { + armSupportRecorder, + disarmSupportRecorder, + downloadSupportBundle, + getSupportRecording, + getSupportRecordingCount, + redactBundle, + stripTokens, +} from '../../utils/support-bundle' + +// The fixed sections go through fetch() rather than axios on purpose: the armed recorder +// captures all axios traffic, and the network section should contain only what the page +// (or the user's recorded actions) actually requested. +const fetchJson = async (url) => { + try { + const response = await fetch(url, { credentials: 'include' }) + const parsed = await response.json().catch(() => null) + return response.ok ? parsed : { unavailable: response.status, body: parsed } + } catch (error) { + return { unavailable: String(error?.message ?? error) } + } +} + +const CippSupportBundleDialog = ({ open, onClose, onRecordingChange }) => { + const queryClient = useQueryClient() + const settings = useSettings() + const [phase, setPhase] = useState('options') + const [redact, setRedact] = useState(true) + const [bundle, setBundle] = useState(null) + const [redactionSummary, setRedactionSummary] = useState(null) + const [progress, setProgress] = useState(0) + const [errorMessage, setErrorMessage] = useState(null) + // True while a manual recording is running. It deliberately survives the dialog being + // closed - the user closes it, reproduces the issue, and comes back to stop. The + // dialog stays mounted in _app, so this state outlives the close. + const [recording, setRecording] = useState(false) + // Invalidates a run when it is cancelled, so a stale run cannot finish later and + // overwrite the state of a newer one. + const runToken = useRef(0) + const modeRef = useRef('page') + + // Reopening lands on the options screen - unless a manual recording is running, in + // which case it lands back on the recording screen. Adjusted during render (the + // React-sanctioned alternative to setState-in-effect). + const [prevOpen, setPrevOpen] = useState(open) + if (open !== prevOpen) { + setPrevOpen(open) + if (open) { + if (recording) { + setPhase('recording') + setProgress(getSupportRecordingCount()) + } else { + setPhase('options') + setBundle(null) + setRedactionSummary(null) + setErrorMessage(null) + setProgress(0) + } + } + } + + // Closing cancels a page capture in flight; a manual recording keeps running. + useEffect(() => { + if (!open && !recording) { + runToken.current++ + disarmSupportRecorder() + } + }, [open, recording]) + + // Live request counter while the dialog is showing an armed recorder. + useEffect(() => { + if (!open || (phase !== 'collecting' && phase !== 'recording')) return + const interval = setInterval( + () => setProgress(getSupportRecordingCount()), + 300 + ) + return () => clearInterval(interval) + }, [open, phase]) + + const assemble = async (token) => { + const localVersion = await fetchJson('/version.json') + const [instance, me, authMe] = await Promise.all([ + fetchJson( + `/api/GetVersion?LocalVersion=${encodeURIComponent(localVersion?.version ?? '')}` + ), + fetchJson('/api/me'), + fetchJson('/.auth/me'), + ]) + if (token !== runToken.current) return + disarmSupportRecorder() + const network = getSupportRecording() + let assembled = { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + instanceHostname: window.location.hostname, + redaction: { enabled: redact }, + client: { + captureMode: modeRef.current, + path: window.location.pathname, + tenant: settings.currentTenant ?? null, + userAgent: navigator.userAgent, + frontendVersion: localVersion?.version ?? null, + }, + instance, + user: { me, authMe }, + network, + } + // Tokens are live credentials and are stripped from every bundle, before and + // independent of the optional identifier redaction. + const stripped = stripTokens(assembled) + assembled = stripped.bundle + assembled.tokensRemoved = stripped.removed + if (redact) { + // The instance's own hostname identifies the installation, not a customer + // tenant - support needs it, so it survives redaction. + const redacted = redactBundle(assembled, { + keepHostnames: [window.location.hostname], + }) + assembled = redacted.bundle + assembled.redaction = { enabled: true, ...redacted.summary } + setRedactionSummary(redacted.summary) + } + setBundle(assembled) + setProgress(network.length) + setPhase('ready') + } + + const failRun = (token, error) => { + if (token !== runToken.current) return + disarmSupportRecorder() + setErrorMessage(String(error?.message ?? error)) + setPhase('error') + } + + const handleCapturePage = async () => { + const token = ++runToken.current + modeRef.current = 'page' + setPhase('collecting') + setProgress(0) + armSupportRecorder() + try { + // Force every query mounted on the current page to hit the API again - the + // recorder only sees axios traffic, so cache reads must become real requests. + await queryClient.refetchQueries({ type: 'active' }) + await assemble(token) + } catch (error) { + failRun(token, error) + } + } + + const handleStartRecording = () => { + ++runToken.current + modeRef.current = 'recording' + setRecording(true) + onRecordingChange?.(true) + armSupportRecorder() + onClose() + } + + const handleStopRecording = async () => { + const token = ++runToken.current + setRecording(false) + onRecordingChange?.(false) + setPhase('collecting') + try { + await assemble(token) + } catch (error) { + failRun(token, error) + } + } + + const handleDiscardRecording = () => { + ++runToken.current + setRecording(false) + onRecordingChange?.(false) + disarmSupportRecorder() + setPhase('options') + setProgress(0) + } + + const failedCount = + bundle?.network?.filter((call) => !call.success).length ?? 0 + const capturedFrom = + bundle?.client?.captureMode === 'recording' + ? 'during the recording' + : 'from this page' + + return ( + + Generate Support File + + {phase === 'options' && ( + + + Capture this page's API requests now, or record while you + reproduce an issue. Either way the file also includes the instance + version, hosting and update details, and your signed-in identity + and roles. + + setRedact(event.target.checked)} + /> + } + label="Redact tenant IDs, domains and email addresses" + /> + + )} + {phase === 'recording' && ( + + + + + Recording — {progress} request{progress === 1 ? '' : 's'}{' '} + captured so far. + + + + Close this dialog, reproduce the issue, then click the recording + indicator to come back and stop. Reloading the browser discards + the recording. + + + )} + {phase === 'collecting' && ( + + + + Collecting — {progress} request{progress === 1 ? '' : 's'}{' '} + captured... + + + )} + {phase === 'ready' && ( + + + Captured {bundle.network.length} request + {bundle.network.length === 1 ? '' : 's'} {capturedFrom} + {failedCount > 0 ? `, of which ${failedCount} failed` : ''}, along + with the instance version, hosting and update details, and your + signed-in identity and roles. + + {redactionSummary ? ( + + Redacted {redactionSummary.emails} email address + {redactionSummary.emails === 1 ? '' : 'es'},{' '} + {redactionSummary.guids} GUID + {redactionSummary.guids === 1 ? '' : 's'} and{' '} + {redactionSummary.domains} domain + {redactionSummary.domains === 1 ? '' : 's'}. + + ) : ( + + The file contains unredacted data from the current page, your + user identity, and instance details. Authentication tokens are + always removed. Only share it with support. + + )} + + )} + {phase === 'error' && ( + + Could not generate the support file: {errorMessage} + + )} + + + {phase === 'options' && ( + <> + + + + + )} + {phase === 'recording' && ( + <> + + + + + )} + {(phase === 'collecting' || phase === 'ready' || phase === 'error') && ( + <> + + + + )} + + + ) +} + +export default CippSupportBundleDialog diff --git a/src/components/CippComponents/CippTabNavigationSection.jsx b/src/components/CippComponents/CippTabNavigationSection.jsx new file mode 100644 index 000000000000..59ae1d7fcd69 --- /dev/null +++ b/src/components/CippComponents/CippTabNavigationSection.jsx @@ -0,0 +1,57 @@ +import { + List, + ListItemButton, + ListItemIcon, + ListItemText, + ListSubheader, +} from '@mui/material' +import { Check } from '@mui/icons-material' +import { getIconByName } from '../../utils/icon-registry' +import { useTabNavigation } from '../../layouts/tab-navigation-context' + +/** + * The tab bar as sheet rows. Rendered inside whichever bottom sheet owns the mobile + * bottom-right corner, so a page never shows two competing navigation affordances. + */ +export const CippTabNavigationSection = ({ title = 'Views', onNavigate }) => { + const tabNav = useTabNavigation() + + if (!tabNav?.enabled || !tabNav.tabs?.length) return null + + return ( + + {title} + + ) : null + } + > + {tabNav.tabs.map((tab) => { + const selected = tab.path === tabNav.currentPath + return ( + { + onNavigate?.() + // Already here — the sheet closing is the whole interaction. + if (!selected) tabNav.onNavigate?.(tab.path) + }} + > + + {getIconByName(tab.icon, { fontSize: 'small' })} + + + {selected && } + + ) + })} + + ) +} diff --git a/src/components/CippComponents/CippTabPicker.jsx b/src/components/CippComponents/CippTabPicker.jsx new file mode 100644 index 000000000000..ba19573da8ad --- /dev/null +++ b/src/components/CippComponents/CippTabPicker.jsx @@ -0,0 +1,100 @@ +import { useState } from 'react' +import { Box, ButtonBase, Typography } from '@mui/material' +import { visuallyHidden } from '@mui/utils' +import { KeyboardArrowDown } from '@mui/icons-material' +import { CippBottomSheet } from './CippBottomSheet' +import { CippTabNavigationSection } from './CippTabNavigationSection' +import { getIconByName } from '../../utils/icon-registry' +import { useTabNavigation } from '../../layouts/tab-navigation-context' + +/** + * The mobile replacement for a tabbed layout's tab bar: a collapsed trigger that opens the tab + * list as a bottom sheet. + * + * Navigation deliberately lives in the content flow rather than in the page FAB — a FAB is for a + * screen's primary action, and putting destinations there also made them unreachable whenever + * something else owned the corner (a card list in select mode draws no FAB at all). + * + * Two presentations, one behaviour: + * block the default, and what every page gets — a full-width row in the slot the desktop + * tab bar occupies. Same control in the same place on every tabbed page. + * compact a chip beside a heading. Only HeaderedTabbedLayout, whose title row has an empty + * right half below md, so navigation there costs no vertical space at all. + */ +export const CippTabPicker = (props) => { + const { variant = 'block', sx } = props + + const [open, setOpen] = useState(false) + const tabNav = useTabNavigation() + const tabs = tabNav?.tabs ?? [] + // One destination is not navigation. Two pages (View Group, View Device) have a single tab and + // used to get a FAB whose sheet offered the page you were already on. + if (!tabNav?.enabled || tabs.length < 2) return null + + const current = tabs.find((tab) => tab.path === tabNav.currentPath) + const label = current?.label ?? 'Views' + const isCompact = variant === 'compact' + + return ( + <> + setOpen(true)} + aria-haspopup="dialog" + sx={{ + minWidth: 0, + display: 'flex', + alignItems: 'center', + textAlign: 'left', + gap: 0.75, + borderRadius: 1, + ...(isCompact + ? { + flexShrink: 0, + // Long labels ("Policies and Settings Deployed" is 30 characters) must not push + // the heading beside them off the row. + maxWidth: '50%', + height: 40, + px: 1.25, + bgcolor: 'action.hover', + } + : { + // Full-width tap target, heading clothes: the chevron is the affordance. + width: '100%', + minHeight: 44, + justifyContent: 'flex-start', + }), + ...sx, + }} + > + {/* No leading icon in the compact chip: it shares a row with a heading that can be a + tenant or user name, and the ~28px it costs comes straight out of that heading. */} + {!isCompact && + getIconByName(current?.icon, { + fontSize: 'small', + sx: { flexShrink: 0, color: 'text.secondary' }, + })} + + {label} + + {/* Not an aria-label: overriding the name would leave the visible text out of it, and + a voice-control user saying "Manage Drift" could no longer activate this. The + hidden suffix extends the name instead of replacing it. */} + + switch view + + {/* Compact rides the control's right edge; the heading form keeps the chevron + beside the text, where a title's disclosure affordance belongs. */} + + + setOpen(false)} title="Views"> + setOpen(false)} /> + + + ) +} diff --git a/src/components/CippComponents/CippTableDialog.jsx b/src/components/CippComponents/CippTableDialog.jsx index e31d4485263b..d1c2e8076628 100644 --- a/src/components/CippComponents/CippTableDialog.jsx +++ b/src/components/CippComponents/CippTableDialog.jsx @@ -1,30 +1,38 @@ -import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material"; -import { Stack } from "@mui/system"; -import { CippDataTable } from "../CippTable/CippDataTable"; - -export const CippTableDialog = (props) => { - const { createDialog, title, fields, api, simpleColumns, ...other } = props; - - return ( - - {title} - - - - - - - - - - ); -}; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, useMediaQuery } from "@mui/material"; +import { Stack } from "@mui/system"; +import { CippDataTable } from "../CippTable/CippDataTable"; + +export const CippTableDialog = (props) => { + const { createDialog, title, fields, api, simpleColumns, ...other } = props; + // Fullscreen on phones so the nested card list gets the viewport (CippApiDialog precedent) + const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md")); + + return ( + + {title} + + + + + + + + + + ); +}; diff --git a/src/components/CippComponents/CippTablePage.jsx b/src/components/CippComponents/CippTablePage.jsx index b95db81b5a0c..af4881a97b81 100644 --- a/src/components/CippComponents/CippTablePage.jsx +++ b/src/components/CippComponents/CippTablePage.jsx @@ -2,6 +2,7 @@ import { Alert, Card, Divider } from "@mui/material"; import { Box, Container, Stack } from "@mui/system"; import { CippDataTable } from "../CippTable/CippDataTable"; import { useSettings } from "../../hooks/use-settings"; +import { useTableViewMode } from "../../hooks/use-breakpoint"; import { CippHead } from "./CippHead"; import { useState, useEffect } from "react"; @@ -29,47 +30,69 @@ export const CippTablePage = (props) => { ...other } = props; const tenant = useSettings().currentTenant; + const viewMode = useTableViewMode({ viewMode: other.viewMode }); + const isCardView = viewMode === "cards"; // Use initialFilters if provided, otherwise use regular filters const activeFilters = initialFilters || filters; + + // Pages without an explicit queryKey have always keyed their query on the title — + // which embeds the tenant. Card view drops the tenant from the DISPLAY title, so the + // cache key must keep carrying it explicitly or tenant switches serve stale data. + const effectiveQueryKey = + queryKey ?? (tenantInTitle && tenant !== null ? `${title} - ${tenant}` : title); + + const table = ( + + ); + return ( <> - - + + {tableFilter} {tenantInTitle && (!tenant || tenant === null) && ( No tenant selected. Please select a tenant from the dropdown above. )} - - - - - + > + + + {table} + + )} diff --git a/src/components/CippComponents/CippTemplateCatalog.jsx b/src/components/CippComponents/CippTemplateCatalog.jsx index 71b004dbbc14..ab2d941ee4ef 100644 --- a/src/components/CippComponents/CippTemplateCatalog.jsx +++ b/src/components/CippComponents/CippTemplateCatalog.jsx @@ -320,7 +320,7 @@ const CompactTemplateList = memo( mb: 1, bgcolor: 'background.paper', '&:hover': { bgcolor: 'action.hover' }, - pr: 20, + pr: { xs: 6, md: 20 }, }} > { {/* Custom Variable - Two-field input */} {watchedRules?.[ruleIndex]?.property?.type === "customVariable" ? ( - + { }} /> - + { disableClearable={true} creatable={false} multiple={multiple} - sx={{ width: width ? width : "400px" }} + // Full width below md by default: the hard 400px overflowed any narrow container + // this selector was dropped into (the old 80%-wide mobile drawer most visibly). + sx={{ width: width ? width : { xs: "100%", md: "400px" } }} placeholder={ tenantList.isFetching ? "Loading Tenants..." diff --git a/src/components/CippComponents/CippTransportRuleDrawer.jsx b/src/components/CippComponents/CippTransportRuleDrawer.jsx index 37e4bcf308fb..2458b12cbf46 100644 --- a/src/components/CippComponents/CippTransportRuleDrawer.jsx +++ b/src/components/CippComponents/CippTransportRuleDrawer.jsx @@ -995,7 +995,7 @@ export const CippTransportRuleDrawer = ({ return ( - + - + - + - + - + - + { multiPost: false, condition: () => canWriteUser, }, + { + label: 'Require Password Change at Next Logon', + type: 'POST', + icon: , + url: '/api/ExecRequirePasswordChange', + data: { + ID: 'id', + }, + confirmText: + 'Require [userPrincipalName] to change their password at next logon? This does not reset the password. Not supported for directory-synced accounts.', + multiPost: false, + condition: () => canWriteUser, + }, { label: 'Set Password Expiration', type: 'POST', diff --git a/src/components/CippComponents/CippUserSwitcher.jsx b/src/components/CippComponents/CippUserSwitcher.jsx new file mode 100644 index 000000000000..444de74ef82c --- /dev/null +++ b/src/components/CippComponents/CippUserSwitcher.jsx @@ -0,0 +1,27 @@ +import { CippEntitySwitcher } from "./CippEntitySwitcher"; + +/** + * The View User pages' title-as-switcher: CippEntitySwitcher preset over the tenant's + * user list, swapping userId so the current tab (View, Edit, Exchange…) is preserved. + */ +export const CippUserSwitcher = ({ title, currentUserId, tenantFilter }) => ( + user.userPrincipalName} + /> +); diff --git a/src/components/CippComponents/CippVariableAutocomplete.jsx b/src/components/CippComponents/CippVariableAutocomplete.jsx index 39d1b49c4047..07ba551b218b 100644 --- a/src/components/CippComponents/CippVariableAutocomplete.jsx +++ b/src/components/CippComponents/CippVariableAutocomplete.jsx @@ -277,8 +277,12 @@ export const CippVariableAutocomplete = React.memo( borderRadius: 1, maxHeight: 240, overflow: "auto", - minWidth: 300, - maxWidth: 500, + // Clamped to the viewport: the Paper shrink-to-fits against unclamped variable + // descriptions, and popper.js can only shift a too-wide popper, not shrink it — + // at the 500px cap a phone got ~110px hanging off the right edge, scrolling the + // whole document sideways. + minWidth: "min(300px, calc(100vw - 32px))", + maxWidth: "min(500px, calc(100vw - 32px))", }} onClick={(e) => { e.stopPropagation(); diff --git a/src/components/CippComponents/EnrollmentProfileTabs.jsx b/src/components/CippComponents/EnrollmentProfileTabs.jsx index cfed94ec5172..1288c9a97e74 100644 --- a/src/components/CippComponents/EnrollmentProfileTabs.jsx +++ b/src/components/CippComponents/EnrollmentProfileTabs.jsx @@ -16,9 +16,12 @@ import { ContentCopy, Delete, EventAvailable, + LaptopChromebook, + LinkOff, QrCode2, Sync, } from '@mui/icons-material' +import { UserGroupIcon } from '@heroicons/react/24/outline' import { CippHead } from './CippHead.jsx' import { CippDataTable } from '../CippTable/CippDataTable.js' import { CippInfoBar } from '../CippCards/CippInfoBar.jsx' @@ -419,7 +422,140 @@ export const AndroidEnterpriseEnrollmentProfiles = () => { export const WindowsAutopilotEnrollmentProfiles = () => { const currentTenant = useSettings().currentTenant + + const groupsQuery = ApiGetCall({ + url: '/api/ListGroups', + data: { tenantFilter: currentTenant }, + queryKey: `ListGroups-${currentTenant}`, + waiting: Boolean(currentTenant), + }) + const groupMap = useMemo(() => { + const map = {} + if (groupsQuery.data) { + for (const g of groupsQuery.data) { + if (g.id) map[g.id] = g.displayName + } + } + return map + }, [groupsQuery.data]) + const autopilotActions = [ + { + label: 'Assign to All Devices', + type: 'POST', + icon: , + url: '/api/ExecAssignAutopilotProfile', + data: { + ProfileId: 'id', + ProfileName: 'displayName', + AssignTo: '!AllDevices', + }, + confirmText: + 'Are you sure you want to assign "[displayName]" to all devices?', + color: 'info', + multiPost: false, + allowResubmit: true, + relatedQueryKeys: [`AutopilotProfiles-${currentTenant}`], + }, + { + label: 'Assign to Custom Group(s)', + type: 'POST', + icon: , + url: '/api/ExecAssignAutopilotProfile', + confirmText: 'Select the target groups for "[displayName]".', + color: 'info', + multiPost: false, + allowResubmit: true, + relatedQueryKeys: [`AutopilotProfiles-${currentTenant}`], + fields: [ + { + type: 'autoComplete', + name: 'GroupIds', + label: 'Group(s)', + multiple: true, + creatable: false, + validators: { required: 'Please select at least one group' }, + api: { + url: '/api/ListGroups', + queryKey: `ListGroups-${currentTenant}`, + tenantFilter: currentTenant, + labelField: (option) => + option?.groupType + ? `${option.displayName} (${option.groupType})` + : (option?.displayName ?? ''), + valueField: 'id', + showRefresh: true, + }, + }, + ], + customDataformatter: (row, action, formData) => ({ + tenantFilter: currentTenant, + ProfileId: row.id, + ProfileName: row.displayName, + AssignTo: 'customGroup', + GroupIds: (formData?.GroupIds || []).map((g) => g.value).filter(Boolean), + }), + }, + { + label: 'Remove Assignment(s)', + type: 'POST', + icon: , + url: '/api/ExecAssignAutopilotProfile', + confirmText: 'Remove assignments from "[displayName]".', + color: 'warning', + multiPost: false, + allowResubmit: true, + relatedQueryKeys: [`AutopilotProfiles-${currentTenant}`], + fields: [ + { + type: 'switch', + name: 'removeAll', + label: 'Remove all assignments', + defaultValue: true, + }, + { + type: 'autoComplete', + name: 'GroupIds', + label: 'Assignment(s) to remove', + multiple: true, + creatable: false, + validators: { + validate: (value, formValues) => { + if (formValues?.removeAll) return true + return (Array.isArray(value) && value.length > 0) || 'Please select at least one assignment' + }, + }, + options: (row) => + (row?.assignments || []) + .map((a) => { + const t = a.target?.['@odata.type'] || '' + if (t.endsWith('allDevicesAssignmentTarget')) { + return { label: 'All Devices', value: 'allDevices' } + } + if (t.endsWith('groupAssignmentTarget') && a.target?.groupId) { + const id = a.target.groupId + const name = groupMap[id] + return { + label: name ? `${name} (${id})` : id, + value: id, + } + } + return null + }) + .filter(Boolean), + condition: { field: 'removeAll', compareType: 'is', compareValue: false }, + }, + ], + customDataformatter: (row, action, formData) => ({ + tenantFilter: currentTenant, + ProfileId: row.id, + ProfileName: row.displayName, + AssignTo: formData?.removeAll ? 'RemoveAll' : 'RemoveGroups', + GroupIds: formData?.removeAll + ? [] + : (formData?.GroupIds || []).map((g) => g.value).filter(Boolean), + }), + }, { label: 'Delete Profile', icon: , diff --git a/src/components/CippComponents/LicenseCard.jsx b/src/components/CippComponents/LicenseCard.jsx index 5e59011903b3..b7f1d2218b27 100644 --- a/src/components/CippComponents/LicenseCard.jsx +++ b/src/components/CippComponents/LicenseCard.jsx @@ -145,7 +145,7 @@ export const LicenseCard = ({ data, isLoading }) => { sx={{ pb: 1 }} /> - + {isLoading ? ( ) : processedData ? ( diff --git a/src/components/CippComponents/MFACard.jsx b/src/components/CippComponents/MFACard.jsx index 634a97decc7b..0a2cc9730fb4 100644 --- a/src/components/CippComponents/MFACard.jsx +++ b/src/components/CippComponents/MFACard.jsx @@ -228,7 +228,7 @@ export const MFACard = ({ data, isLoading }) => { sx={{ pb: 1 }} /> - + {isLoading ? ( ) : processedData ? ( diff --git a/src/components/CippComponents/SecureScoreCard.jsx b/src/components/CippComponents/SecureScoreCard.jsx index e20920a01e64..6117faa2ed9d 100644 --- a/src/components/CippComponents/SecureScoreCard.jsx +++ b/src/components/CippComponents/SecureScoreCard.jsx @@ -11,9 +11,37 @@ import { Tooltip as RechartsTooltip, ReferenceLine, } from 'recharts' +import { useIsMobileLayout } from '../../hooks/use-breakpoint' + +/** + * Axis configuration for the score trend. + * + * Exported because it is the whole of the narrow-screen fix and there is nothing rendered to + * assert against: recharts reads its axis children's props directly rather than mounting them, + * so an XAxis cannot be captured by wrapping it. + * + * `interval: 0` draws a label for every point. Thirteen dates fit across a desktop card and + * overlap into one smear at 390px — "Jul 27Jul 28Jul 29". A narrow chart hands spacing back to + * recharts and lets it drop whatever will not fit. + */ +export const secureScoreAxisProps = ({ isMobile, ticks }) => ({ + x: { + tick: { fontSize: isMobile ? 10 : 12 }, + tickMargin: 8, + ticks: isMobile ? undefined : ticks, + interval: isMobile ? 'preserveStartEnd' : 0, + minTickGap: isMobile ? 28 : 5, + }, + y: { + tick: { fontSize: isMobile ? 10 : 12 }, + tickMargin: 8, + width: isMobile ? 34 : undefined, + }, +}) export const SecureScoreCard = ({ data, isLoading }) => { const router = useRouter() + const isMobile = useIsMobileLayout() return ( { percentage: Math.round((score.currentScore / score.maxScore) * 100), })) const ticks = chartData.map((d) => d.date) + const axis = secureScoreAxisProps({ isMobile, ticks }) return ( - + Math.round(value)} /> diff --git a/src/components/CippComponents/TenantMetricsGrid.jsx b/src/components/CippComponents/TenantMetricsGrid.jsx index 35eda0143286..404f1b6601b7 100644 --- a/src/components/CippComponents/TenantMetricsGrid.jsx +++ b/src/components/CippComponents/TenantMetricsGrid.jsx @@ -1,5 +1,5 @@ -import { Box, Grid, Tooltip, Avatar, Typography, Skeleton } from "@mui/material"; -import { useRouter } from "next/router"; +import { Box, Grid, Tooltip, Avatar, Typography, Skeleton } from '@mui/material' +import { useRouter } from 'next/router' import { Person as UserIcon, PersonOutline as GuestIcon, @@ -7,72 +7,75 @@ import { Apps as AppsIcon, Devices as DevicesIcon, PhoneAndroid as ManagedIcon, -} from "@mui/icons-material"; +} from '@mui/icons-material' const formatNumber = (num) => { - if (num >= 1000000) return (num / 1000000).toFixed(1) + "M"; - if (num >= 1000) return (num / 1000).toFixed(1) + "K"; - return num?.toString() || "0"; -}; + if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M' + if (num >= 1000) return (num / 1000).toFixed(1) + 'K' + return num?.toString() || '0' +} export const TenantMetricsGrid = ({ data, isLoading }) => { - const router = useRouter(); + const router = useRouter() const metrics = [ { - label: "Users", + label: 'Users', value: data?.UserCount || 0, icon: UserIcon, - color: "primary", - path: "/identity/administration/users", + color: 'primary', + path: '/identity/administration/users', }, { - label: "Guests", + label: 'Guests', value: data?.GuestCount || 0, icon: GuestIcon, - color: "info", - path: "/identity/administration/users", + color: 'info', + path: '/identity/administration/users', }, { - label: "Groups", + label: 'Groups', value: data?.GroupCount || 0, icon: GroupIcon, - color: "secondary", - path: "/identity/administration/groups", + color: 'secondary', + path: '/identity/administration/groups', }, { - label: "Service Principals", + label: 'Service Principals', value: data?.ApplicationCount || 0, icon: AppsIcon, - color: "error", - path: "/tenant/administration/applications/enterprise-apps", + color: 'error', + path: '/tenant/administration/applications/enterprise-apps', }, { - label: "Devices", + label: 'Devices', value: data?.DeviceCount || 0, icon: DevicesIcon, - color: "warning", - path: "/identity/administration/devices", + color: 'warning', + path: '/identity/administration/devices', }, { - label: "Managed", + label: 'Managed', value: data?.ManagedDeviceCount || 0, icon: ManagedIcon, - color: "success", - path: "/identity/administration/devices", + color: 'success', + path: '/identity/administration/devices', }, - ]; + ] const handleClick = (metric) => { if (metric.path) { - router.push(metric.path); + router.push(metric.path) } - }; + } return ( {metrics.map((metric) => { - const IconComponent = metric.icon; + const IconComponent = metric.icon + // Two-up at every width on purpose, phones included: the tile is sized for a + // narrow column (28px avatar, 0.6rem label) and the dashboard reads better as a + // 2x3 block than as six stacked rows. mobile-layout-ok return ( { handleClick(metric)} sx={{ - display: "flex", - alignItems: "center", + display: 'flex', + alignItems: 'center', gap: { xs: 1, sm: 1.5 }, p: { xs: 1, sm: 1.5, md: 2 }, border: 1, - borderColor: "divider", + borderColor: 'divider', borderRadius: 1, - cursor: "pointer", + cursor: 'pointer', minWidth: 0, - transition: "all 0.2s ease-in-out", - "&:hover": { + transition: 'all 0.2s ease-in-out', + '&:hover': { borderColor: `${metric.color}.main`, - backgroundColor: "action.hover", - transform: "translateY(-2px)", - boxShadow: "0 4px 8px rgba(0,0,0,0.1)", + backgroundColor: 'action.hover', + transform: 'translateY(-2px)', + boxShadow: '0 4px 8px rgba(0,0,0,0.1)', }, }} > @@ -109,26 +112,38 @@ export const TenantMetricsGrid = ({ data, isLoading }) => { flexShrink: 0, }} > - + {metric.label} - - {isLoading ? : formatNumber(metric.value)} + + {isLoading ? ( + + ) : ( + formatNumber(metric.value) + )} - ); + ) })} - ); -}; + ) +} diff --git a/src/components/CippFormPages/CippAddEditUser.jsx b/src/components/CippFormPages/CippAddEditUser.jsx index 4143b6c9d9a6..3b7290e47d6e 100644 --- a/src/components/CippFormPages/CippAddEditUser.jsx +++ b/src/components/CippFormPages/CippAddEditUser.jsx @@ -10,6 +10,7 @@ import { CippFormLicenseSelector } from '../CippComponents/CippFormLicenseSelect import { Grid } from '@mui/system' import { ApiGetCall } from '../../api/ApiCall' import { useSettings } from '../../hooks/use-settings' +import { useQueryClient } from '@tanstack/react-query' import { useWatch } from 'react-hook-form' import { useEffect, useMemo, useRef, useState } from 'react' import { useRouter } from 'next/router' @@ -158,6 +159,32 @@ const CippAddEditUser = (props) => { AddToGroups: watcher[3], } + // Duplicate-username warning. The Users table already pulled the tenant's user list into the + // tanstack cache when it loaded, so this reads that cache and makes no API request. The entry + // is an infinite query (the table pages through nextLinks), so every page must be flattened - + // checking one page would miss most of the tenant. Warning-only: the cache can be partial or + // stale, so no conflict found is never presented as the name being available. + const queryClient = useQueryClient() + const usernameValue = useWatch({ control: formControl.control, name: 'username' }) + const primDomainValue = useWatch({ control: formControl.control, name: 'primDomain' }) + const usernameConflict = useMemo(() => { + if (formType !== 'add' || !usernameValue || !primDomainValue?.value) return null + const cachedUsers = queryClient + .getQueryData([`Users - ${tenantDomain}`]) + ?.pages?.flatMap((page) => page?.Results ?? []) + if (!cachedUsers?.length) return null + const candidateUPN = `${usernameValue}@${primDomainValue.value}`.toLowerCase() + const candidateSmtp = `smtp:${candidateUPN}` + return ( + cachedUsers.find( + (user) => + user?.userPrincipalName?.toLowerCase() === candidateUPN || + (Array.isArray(user?.proxyAddresses) && + user.proxyAddresses.some((address) => address?.toLowerCase() === candidateSmtp)) + ) ?? null + ) + }, [formType, usernameValue, primDomainValue?.value, tenantDomain, queryClient]) + // Helper function to generate username from template format const generateUsername = ( format, @@ -601,6 +628,13 @@ const CippAddEditUser = (props) => { showRefresh={true} /> + {formType === 'add' && usernameConflict && ( + + + {`${usernameValue}@${primDomainValue?.value} is already in use by "${usernameConflict.displayName}" (${usernameConflict.userPrincipalName}).`} + + + )} { Settings - + { - + { compareValue="(0 available)" labelCompare={true} > - + { )} - + { {userSettingsDefaults?.userAttributes ?.filter((attribute) => attribute.value !== 'sponsor') .map((attribute, idx) => ( - + { {formType === 'add' && ( <> - + { formControl={formControl} /> - + { formControl={formControl} /> - + { formControl={formControl} /> - + { { label: "Security Group", value: "generic" }, { label: "Microsoft 365 Group", value: "m365" }, { label: "Dynamic Group", value: "dynamic" }, - { label: "Dynamic Distribution Group", value: "dynamicdistribution" }, { label: "Distribution List", value: "distribution" }, { label: "Mail Enabled Security Group", value: "security" }, ]} @@ -134,8 +133,8 @@ const CippAddGroupForm = (props) => { { { label: "Security Group", value: "generic" }, { label: "Microsoft 365 Group", value: "m365" }, { label: "Dynamic Group", value: "dynamic" }, - { label: "Dynamic Distribution Group", value: "dynamicDistribution" }, { label: "Distribution List", value: "distribution" }, { label: "Mail Enabled Security Group", value: "security" }, ]} diff --git a/src/components/CippFormPages/CippExchangeSettingsForm.jsx b/src/components/CippFormPages/CippExchangeSettingsForm.jsx index 0427d3d27d1f..812e28c84970 100644 --- a/src/components/CippFormPages/CippExchangeSettingsForm.jsx +++ b/src/components/CippFormPages/CippExchangeSettingsForm.jsx @@ -221,7 +221,7 @@ const CippExchangeSettingsForm = (props) => { ]} /> - + { - + { ...other } = props const router = useRouter() + const ancestorHasGutters = useTabNavigation()?.providesGutters ?? false + // On mobile the tab picker directly above already reads as this page's heading whenever it + // shows the same text this h4 would (SAM App Roles printed its name twice in a row). The + // claim compares what would actually render, page-type prefix included; a row that also + // carries a titleButton keeps rendering, because the button has nowhere else to live. + const renderedTitle = hidePageType ? title : `${formPageType} - ${title}` + const titleClaimed = useTitleClaimedByTabPicker(renderedTitle) && !titleButton //check if there are const postCall = ApiPostCall({ datafromUrl: true, @@ -135,19 +143,29 @@ const CippFormPage = (props) => { flexGrow: 1, }} > - + - {!hideTitle && ( + {!hideTitle && !titleClaimed && ( -
    {!hidePageType && <>{formPageType} - } {title} {titleButton && titleButton} -
    +
    )} @@ -160,7 +178,16 @@ const CippFormPage = (props) => { {!hideSubmit && ( - + {/* Stacked full-width on phones: Submit is the primary action of the whole + page and shouldn't be a narrow target crowded by the extra buttons. */} + {addedButtons && addedButtons} + {/* Off by default: six of the eight hosts already put a Download in their dialog + actions, and two of them side by side is what this looked like on a phone. */} + {showDownload && ( + + )} + + + ) +} + +/** + * Drop-in for ``: identical on desktop, a platform handoff below md. + * + * `title` labels the card and `fileName` names the download; both are mobile-only, as is + * `showDownload` — pass it only where the host has no download action of its own. `viewerKey` + * is applied to the desktop iframe alone: one caller remounts it per render to dodge a + * react-pdf error, and doing that on mobile would rebuild the blob every render. + */ +export const CippPdfPreview = (props) => { + const { children, fileName, title, viewerKey, showDownload = false, ...viewerProps } = props + const isMobile = useIsMobileLayout() + + if (isMobile) { + return ( + + ) + } + + return ( + + {children} + + ) +} + +export default CippPdfPreview diff --git a/src/components/CippPdf/PermissionsReportButton.jsx b/src/components/CippPdf/PermissionsReportButton.jsx index e5babd008db3..9256f8d7b78f 100644 --- a/src/components/CippPdf/PermissionsReportButton.jsx +++ b/src/components/CippPdf/PermissionsReportButton.jsx @@ -12,7 +12,8 @@ import { Typography, } from '@mui/material' import { Close, Download, PictureAsPdf } from '@mui/icons-material' -import { PDFViewer, PDFDownloadLink } from '@react-pdf/renderer' +import { PDFDownloadLink } from '@react-pdf/renderer' +import { CippPdfPreview } from './CippPdfPreview' import { AlertBox, Bold, @@ -476,9 +477,14 @@ export const PermissionsReportButton = ({ permissionsData, tenantName }) => { {dialogOpen && ( - + {documentNode} - + )} diff --git a/src/components/CippPdf/SharingReportButton.jsx b/src/components/CippPdf/SharingReportButton.jsx index 136a515f0c93..1c09a19fdf93 100644 --- a/src/components/CippPdf/SharingReportButton.jsx +++ b/src/components/CippPdf/SharingReportButton.jsx @@ -12,7 +12,8 @@ import { Typography, } from '@mui/material' import { Close, Download, PictureAsPdf } from '@mui/icons-material' -import { PDFViewer, PDFDownloadLink } from '@react-pdf/renderer' +import { PDFDownloadLink } from '@react-pdf/renderer' +import { CippPdfPreview } from './CippPdfPreview' import { AlertBox, Bold, @@ -467,9 +468,14 @@ export const SharingReportButton = ({ sharingData, tenantName }) => { {dialogOpen && ( - + {documentNode} - + )} diff --git a/src/components/CippPdf/previewSampleData.js b/src/components/CippPdf/previewSampleData.js index f3a607f5088d..a021b7bb7e8c 100644 --- a/src/components/CippPdf/previewSampleData.js +++ b/src/components/CippPdf/previewSampleData.js @@ -79,6 +79,18 @@ export const SAMPLE_EXECUTIVE = { isEncrypted: true, lastSyncDateTime: '2026-08-04T21:30:00Z', }, + // A Windows 365 Cloud PC: isEncrypted is false (no BitLocker) but the disk is + // platform-encrypted by Azure, so the report counts it as encrypted. + { + deviceName: 'CPC-SAMPLE-005', + operatingSystem: 'Windows', + complianceState: 'compliant', + isEncrypted: false, + deviceType: 'cloudPC', + model: 'Cloud PC Enterprise 2vCPU/8GB/128GB', + manufacturer: 'Microsoft Corporation', + lastSyncDateTime: '2026-08-05T08:20:00Z', + }, ], // Also a plain array — `conditionalAccessData?.data?.Results` in the real report. conditionalAccessData: [ diff --git a/src/components/CippSettings/CippAppServiceDomains.jsx b/src/components/CippSettings/CippAppServiceDomains.jsx index 480aa73cf450..4f7c7b41c3ec 100644 --- a/src/components/CippSettings/CippAppServiceDomains.jsx +++ b/src/components/CippSettings/CippAppServiceDomains.jsx @@ -85,12 +85,12 @@ const HOSTNAME_REGEX = /^(\*\.)?([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i; const InfoRow = ({ label, value, copy = true }) => ( - + {label} - + {value || "—"} diff --git a/src/components/CippSettings/CippBrandingSettings.jsx b/src/components/CippSettings/CippBrandingSettings.jsx index 6a01076a5a58..44ca040a3b06 100644 --- a/src/components/CippSettings/CippBrandingSettings.jsx +++ b/src/components/CippSettings/CippBrandingSettings.jsx @@ -1436,7 +1436,7 @@ const CippBrandingSettings = () => { }, }} /> - + { + + + {/* Version transitions recorded at warmup - answers "when did this instance land + on the current build, and what was it on before?" without reading container + logs. Rows come newest first from the Status payload. */} + containerStatus.refetch()} + simpleColumns={[ + 'RecordedAt', + 'PreviousVersion', + 'NewVersion', + 'ImageTag', + ]} + /> +
    diff --git a/src/components/CippSettings/CippGDAP/CippFlowDiagram.jsx b/src/components/CippSettings/CippGDAP/CippFlowDiagram.jsx index a6a2e7ada925..c3e2dd411a90 100644 --- a/src/components/CippSettings/CippGDAP/CippFlowDiagram.jsx +++ b/src/components/CippSettings/CippGDAP/CippFlowDiagram.jsx @@ -77,7 +77,7 @@ export const CippFlowDiagram = ({ )} {node.chips && node.chips.length > 0 && ( - + {node.chips.map((chip, chipIndex) => ( ))} diff --git a/src/components/CippSettings/CippGDAP/CippGDAPTraceResults.jsx b/src/components/CippSettings/CippGDAP/CippGDAPTraceResults.jsx index ff34b407388a..2d893fbe1bbe 100644 --- a/src/components/CippSettings/CippGDAP/CippGDAPTraceResults.jsx +++ b/src/components/CippSettings/CippGDAP/CippGDAPTraceResults.jsx @@ -468,7 +468,7 @@ export const CippGDAPTraceResults = ({ data, isLoading, error }) => { > Additional Roles: - + {group.roles.slice(1).map((role, roleIndex) => ( {relationshipName && ( - + Relationship: {relationshipName} diff --git a/src/components/CippSettings/CippGDAPResults.jsx b/src/components/CippSettings/CippGDAPResults.jsx index 306c7451eb32..dd997acf931c 100644 --- a/src/components/CippSettings/CippGDAPResults.jsx +++ b/src/components/CippSettings/CippGDAPResults.jsx @@ -68,6 +68,15 @@ export const CippGDAPResults = (props) => { }; const gdapTests = [ + { + resultProperty: "GDAPIssues", + matchProperty: "Issue", + match: ".+Partner Center API.+", + count: 0, + successMessage: "Partner Center API access is granted to the SAM application", + failureMessage: + "The SAM application cannot access the Partner Center API. Click Details for more information.", + }, { resultProperty: "Memberships", matchProperty: "displayName", @@ -143,7 +152,15 @@ export const CippGDAPResults = (props) => { )} {!importReport && executeCheck?.isFetching ? ( - + + {[70, 85, 60, 75].map((width, index) => ( + + + + + + ))} + ) : !importReport && executeCheck?.isError ? ( Failed to load GDAP check results. Please try refreshing or contact support if the issue diff --git a/src/components/CippSettings/CippPermissionReport.jsx b/src/components/CippSettings/CippPermissionReport.jsx index 7a7e72e74402..1d5794f77f39 100644 --- a/src/components/CippSettings/CippPermissionReport.jsx +++ b/src/components/CippSettings/CippPermissionReport.jsx @@ -1,12 +1,24 @@ -import { Button, Stack, SvgIcon, Tooltip } from "@mui/material"; +import { + Button, + List, + ListItemButton, + ListItemIcon, + ListItemText, + Stack, + SvgIcon, + Tooltip, +} from "@mui/material"; import { Close, ContentPasteGo, FileDownload, FileUpload } from "@mui/icons-material"; import { ApiGetCall } from "../../api/ApiCall"; import { useDialog } from "../../hooks/use-dialog"; +import { useIsMobileLayout } from "../../hooks/use-breakpoint"; import { CippApiDialog } from "../CippComponents/CippApiDialog"; +import { CippPageActionsFab } from "../CippComponents/CippPageActionsFab"; import { useState } from "react"; export const CippPermissionReport = (props) => { const { importReport, setImportReport } = props; + const isMobile = useIsMobileLayout(); const [importError, setImportError] = useState(false); const [currentFile, setCurrentFile] = useState(null); const createDialog = useDialog(); @@ -175,9 +187,8 @@ export const CippPermissionReport = (props) => { } }; - return ( + const reportButtons = ( <> - )} - + + ); + + return ( + <> + {/* Page-level utilities: a row of contained buttons on desktop, but three of those + stacked full-width at 390px read as a banner wall — on mobile they ride in the + page-actions FAB sheet as plain list rows, uniform with every other sheet action. + TabbedLayout no longer puts anything in that corner, so the FAB is this page's own. */} + {isMobile ? ( + + + + + + + + + {/* The sheet stays mounted (keepMounted), so the hidden input survives the + sheet closing while the OS file picker is up. */} + + + + + + + + + + + + + + {importReport && ( + setImportReport(false)} sx={{ minHeight: 48 }}> + + + + + + )} + {importError && ( + setImportError(false)} + sx={{ minHeight: 48, color: "error.main" }} + > + + + + + + )} + + + ) : ( + + {reportButtons} + + )} { /> )} {!importReport && executeCheck?.isFetching ? ( - + + {[70, 85, 60, 75].map((width, index) => ( + + + + + + ))} + ) : !importReport && executeCheck?.isError ? ( Failed to load permission check results. Please try refreshing or contact support if the diff --git a/src/components/CippSettings/CippRoleAddEdit.jsx b/src/components/CippSettings/CippRoleAddEdit.jsx index 75192d394004..364dd38a70b7 100644 --- a/src/components/CippSettings/CippRoleAddEdit.jsx +++ b/src/components/CippSettings/CippRoleAddEdit.jsx @@ -1,9 +1,10 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { Box, Button, Alert, + Chip, Typography, Accordion, AccordionSummary, @@ -11,6 +12,8 @@ import { Stack, SvgIcon, Skeleton, + ToggleButton, + ToggleButtonGroup, } from "@mui/material"; import { Grid } from "@mui/system"; @@ -25,6 +28,15 @@ import { InformationCircleIcon } from "@heroicons/react/24/outline"; import { CippApiResults } from "../CippComponents/CippApiResults"; import cippRoles from "../../data/cipp-roles.json"; import { GroupHeader, GroupItems } from "../CippComponents/CippAutocompleteGrouping"; +import { + matchPattern, + flattenPermissionTree, + expandRules, + rulesToFlatMap, + flatMapToRules, + validateRulePattern, + buildRuleSuggestions, +} from "../../utils/permission-rules"; export const CippRoleAddEdit = ({ selectedRole }) => { const updatePermissions = ApiPostCall({ @@ -38,6 +50,11 @@ export const CippRoleAddEdit = ({ selectedRole }) => { const [updateDefaults, setUpdateDefaults] = useState(false); const [baseRolePermissions, setBaseRolePermissions] = useState({}); const [isBaseRole, setIsBaseRole] = useState(false); + // New roles start in simple (pattern) mode; existing roles pick their mode in the + // reset effect based on whether their stored rules contain wildcards. + const [permissionMode, setPermissionMode] = useState(selectedRole ? "advanced" : "simple"); + const [gridDiverged, setGridDiverged] = useState(false); + const [rulePreviewVisible, setRulePreviewVisible] = useState(false); const formControl = useForm({ mode: "onChange", @@ -47,6 +64,8 @@ export const CippRoleAddEdit = ({ selectedRole }) => { BlockedEndpoints: [], IPRange: [], Permissions: {}, + PermissionRulesInclude: [], + PermissionRulesExclude: [], }, }); @@ -76,6 +95,20 @@ export const CippRoleAddEdit = ({ selectedRole }) => { const selectedPermissions = useWatch({ control: formControl.control, name: "Permissions" }); const selectedEntraGroup = useWatch({ control: formControl.control, name: "EntraGroup" }); const ipRanges = useWatch({ control: formControl.control, name: "IPRange" }); + const includeRules = useWatch({ control: formControl.control, name: "PermissionRulesInclude" }); + const excludeRules = useWatch({ control: formControl.control, name: "PermissionRulesExclude" }); + const baseRoleTemplate = useWatch({ control: formControl.control, name: "BaseRoleTemplate" }); + + // "Start from a built-in role": copy its patterns into the rule fields as an + // editable starting point, then clear the picker so it acts as a one-shot action. + useEffect(() => { + const roleName = baseRoleTemplate?.value; + if (!roleName || !cippRoles[roleName]) return; + const toOptions = (list) => (list || []).map((pattern) => ({ label: pattern, value: pattern })); + formControl.setValue("PermissionRulesInclude", toOptions(cippRoles[roleName].include)); + formControl.setValue("PermissionRulesExclude", toOptions(cippRoles[roleName].exclude)); + formControl.setValue("BaseRoleTemplate", null); + }, [baseRoleTemplate]); const { data: apiPermissions = [], @@ -105,9 +138,38 @@ export const CippRoleAddEdit = ({ selectedRole }) => { }); const tenants = pages[0] || []; - const matchPattern = (pattern, value) => { - const regex = new RegExp(`^${pattern.replace("*", ".*")}$`); - return regex.test(value); + const permissionUniverse = useMemo(() => flattenPermissionTree(apiPermissions), [apiPermissions]); + const ruleSuggestions = useMemo(() => buildRuleSuggestions(apiPermissions), [apiPermissions]); + const currentRules = useMemo( + () => ({ + Include: (includeRules || []).map((o) => o?.value || o).filter(Boolean), + Exclude: (excludeRules || []).map((o) => o?.value || o).filter(Boolean), + }), + [includeRules, excludeRules] + ); + const ruleExpansion = useMemo( + () => expandRules(currentRules, permissionUniverse), + [currentRules, permissionUniverse] + ); + // Login breaks without CIPP.Core.Read; save auto-adds it when rules miss it. + const coreCovered = ruleExpansion.matched.some((p) => p.startsWith("CIPP.Core.")); + + const handleModeChange = (_event, newMode) => { + if (!newMode || newMode === permissionMode) return; + if (newMode === "advanced") { + // Expand rules into the grid so the advanced view reflects the same role. + if (currentRules.Include.length > 0) { + formControl.setValue("Permissions", rulesToFlatMap(currentRules, apiPermissions)); + } + setGridDiverged(false); + } else { + const rulesGrid = rulesToFlatMap(currentRules, apiPermissions); + const diverged = + currentRules.Include.length > 0 && + Object.keys(rulesGrid).some((key) => (selectedPermissions?.[key] ?? null) !== rulesGrid[key]); + setGridDiverged(diverged); + } + setPermissionMode(newMode); }; const getFunctionDescriptionText = (description) => { @@ -277,6 +339,10 @@ export const CippRoleAddEdit = ({ selectedRole }) => { value: ip, })) || []; + const storedRules = currentPermissions?.PermissionRules; + const toRuleOptions = (list) => + Array.isArray(list) ? list.map((pattern) => ({ label: pattern, value: pattern })) : []; + formControl.reset({ Permissions: basePermissions && Object.keys(basePermissions).length > 0 @@ -288,7 +354,16 @@ export const CippRoleAddEdit = ({ selectedRole }) => { BlockedEndpoints: processedBlockedEndpoints, IPRange: processedIPRanges, EntraGroup: currentPermissions?.EntraGroup, + PermissionRulesInclude: toRuleOptions(storedRules?.Include), + PermissionRulesExclude: toRuleOptions(storedRules?.Exclude), }); + if (currentPermissions) { + // Wildcard roles open in simple mode; migrated concrete-string roles open in + // the grid, which is the friendlier view of an explicit list. + const hasWildcards = storedRules?.Include?.some((pattern) => pattern.includes("*")); + setPermissionMode(hasWildcards ? "simple" : "advanced"); + setGridDiverged(false); + } } }, [customRoleList, customRoleListSuccess, tenantsSuccess, baseRolePermissions]); @@ -383,11 +458,28 @@ export const CippRoleAddEdit = ({ selectedRole }) => { return ip?.value || ip; }) || []; + // PermissionRules is the canonical format for both modes: simple mode sends the + // authored patterns, advanced mode sends concrete strings derived from the grid. + // Permissions stays as a flat snapshot for older backends. + const activeRules = + permissionMode === "simple" + ? { + Include: + coreCovered || currentRules.Include.length === 0 + ? currentRules.Include + : [...currentRules.Include, "CIPP.Core.Read"], + Exclude: currentRules.Exclude, + } + : flatMapToRules(selectedPermissions); + const snapshotPermissions = + permissionMode === "simple" ? rulesToFlatMap(activeRules, apiPermissions) : selectedPermissions; + updatePermissions.mutate({ url: "/api/ExecCustomRole?Action=AddUpdate", data: { RoleName: values?.["RoleName"], - Permissions: selectedPermissions, + Permissions: snapshotPermissions, + PermissionRules: activeRules, EntraGroup: selectedEntraGroup, AllowedTenants: processedAllowedTenants, BlockedTenants: processedBlockedTenants, @@ -409,14 +501,15 @@ export const CippRoleAddEdit = ({ selectedRole }) => { return ( {obj} - + + + )} + {currentRules.Include.length > 0 && !coreCovered && ( + + CIPP.Core.Read is required to sign in and will be added automatically when + you save. + + )} + setRulePreviewVisible(false)} + title="Effective Permissions" + size="lg" + > + + + Permissions granted by the current patterns — expand one to see the API + endpoints it serves. Struck-through entries were matched by an include + pattern but removed by an exclusion. + + {ruleExpansion.matched.map((permission) => { + const [permCat, permObj, permType] = permission.split("."); + // A ReadWrite grant also serves the Read endpoints (enforcement + // matches loosely), so show them unless Read is granted separately. + const sections = [ + { type: permType, endpoints: apiPermissions?.[permCat]?.[permObj]?.[permType] }, + ]; + if ( + permType === "ReadWrite" && + apiPermissions?.[permCat]?.[permObj]?.Read && + !ruleExpansion.matched.includes(`${permCat}.${permObj}.Read`) + ) { + sections.push({ + type: "Read (included by ReadWrite)", + endpoints: apiPermissions[permCat][permObj].Read, + }); + } + const endpointCount = sections.reduce( + (total, section) => total + Object.keys(section.endpoints || {}).length, + 0 + ); + return ( + + } + sx={{ "& .MuiAccordionSummary-content": { minWidth: 0 } }} + > + + + {permission} + + + + + + + {sections.map((section) => ( + + {sections.length > 1 && ( + {section.type} + )} + {Object.keys(section.endpoints || {}).map((apiKey) => { + const apiFunction = section.endpoints[apiKey]; + const description = getFunctionDescriptionText( + apiFunction.Description + ); + return ( + + + {apiFunction.Name} + + {description && ( + + {description} + + )} + + ); + })} + + ))} + + + + ); + })} + {Object.entries(ruleExpansion.excludedBy).map(([permission, pattern]) => ( + + {permission} (excluded by {pattern}) + + ))} + + )} - + {(isBaseRole || permissionMode === "advanced") && ( <> - {Object.keys(apiPermissions) - .sort() - .map((cat, catIndex) => ( - - }>{cat} - - {Object.keys(apiPermissions[cat]) - .sort() - .map((obj, index) => { - const readOnly = baseRolePermissions?.[cat] ? true : false; - return ( - - - - ); - })} - - - ))} + {!isBaseRole && ( + + Set All Permissions + + + + + + )} + + <> + {Object.keys(apiPermissions) + .sort() + .map((cat, catIndex) => ( + + }> + {cat} + + + {Object.keys(apiPermissions[cat]) + .sort() + .map((obj, index) => { + const readOnly = baseRolePermissions?.[cat] ? true : false; + return ( + + + + ); + })} + + + ))} + + - + )} )} - + - + {selectedEntraGroup && ( This role will be assigned to the Entra Group:{" "} @@ -898,7 +1274,27 @@ export const CippRoleAddEdit = ({ selectedRole }) => { )} - {selectedPermissions && apiPermissionSuccess && ( + {!isBaseRole && permissionMode === "simple" && currentRules.Include.length > 0 && ( + <> +
    Permission Rules
    +
      + {currentRules.Include.map((pattern) => ( +
    • + + {pattern} +
    • + ))} + {currentRules.Exclude.map((pattern) => ( +
    • + − {pattern} +
    • + ))} +
    + + {ruleExpansion.matched.length} permissions granted + + + )} + {(isBaseRole || permissionMode === "advanced") && selectedPermissions && apiPermissionSuccess && ( <>
    Selected Permissions
      @@ -917,8 +1313,8 @@ export const CippRoleAddEdit = ({ selectedRole }) => {
    )} -
    -
    + + @@ -931,7 +1327,13 @@ export const CippRoleAddEdit = ({ selectedRole }) => { customRoleListFetching || apiPermissionFetching || tenantsFetching || - !formState.isValid + !formState.isValid || + (!isBaseRole && + permissionMode === "simple" && + (currentRules.Include.length === 0 || + [...currentRules.Include, ...currentRules.Exclude].some( + (pattern) => !validateRulePattern(pattern) + ))) } startIcon={ diff --git a/src/components/CippSettings/CippRoles.jsx b/src/components/CippSettings/CippRoles.jsx index 66f637f74791..835af4f1048e 100644 --- a/src/components/CippSettings/CippRoles.jsx +++ b/src/components/CippSettings/CippRoles.jsx @@ -1,7 +1,10 @@ import React from "react"; -import { Box, Button, SvgIcon } from "@mui/material"; +import { Alert, Box, Button, Chip, SvgIcon, Typography } from "@mui/material"; +import { useQueryClient } from "@tanstack/react-query"; import { CippDataTable } from "../CippTable/CippDataTable"; -import { PencilIcon, TrashIcon, DocumentDuplicateIcon } from "@heroicons/react/24/outline"; +import { PencilIcon, TrashIcon, DocumentDuplicateIcon, EyeIcon } from "@heroicons/react/24/outline"; +import { usePermissions } from "../../hooks/use-permissions"; +import { enterImpersonation } from "../../utils/impersonation"; import NextLink from "next/link"; import { CippPropertyListCard } from "../../components/CippCards/CippPropertyListCard"; import { getCippTranslation } from "../../utils/get-cipp-translation"; @@ -10,7 +13,48 @@ import { Stack } from "@mui/system"; import { CippCopyToClipBoard } from "../CippComponents/CippCopyToClipboard"; const CippRoles = () => { + const queryClient = useQueryClient(); + const { userRoles } = usePermissions(); + // While impersonating, /me reports the impersonated roles, so this action disappears + // automatically — no nested impersonation; the only way back is the banner's Exit. + const isSuperAdmin = userRoles?.includes("superadmin"); + const actions = [ + ...(isSuperAdmin + ? [ + { + label: "Impersonate Role", + icon: ( + + + + ), + confirmText: ( + + + Impersonate this role? CIPP will reload and behave as if you only hold this + role — including its tenant restrictions — until you click Exit in the banner + at the top of the page. IP restrictions are not simulated. + + + This tests a single role in isolation, not role combinations. + For users holding several roles, custom roles are restrictive, not + additive: combined with a base role like editor or readonly they can + only narrow access, so a real user's effective permissions may differ from + what you see here. + + + ), + // Row-menu passes (row, action, formData); the offcanvas property card passes + // (item, data, {}) — resolve the row defensively. + customFunction: (a, b) => { + const row = a?.RoleName ? a : b; + if (row?.RoleName) enterImpersonation(row.RoleName, queryClient); + }, + condition: (row) => row?.RoleName?.toLowerCase() !== "superadmin", + }, + ] + : []), { label: "Edit", icon: ( @@ -81,9 +125,27 @@ const CippRoles = () => { } }); + const rules = data["PermissionRules"]; + const hasRules = Array.isArray(rules?.Include) && rules.Include.length > 0; + if (hasRules) { + properties.push({ + label: "Permission Rules", + value: ( + + {rules.Include.map((pattern, idx) => ( + + ))} + {(rules.Exclude || []).map((pattern, idx) => ( + + ))} + + ), + }); + } + if (data["Permissions"] && Object.keys(data["Permissions"]).length > 0) { properties.push({ - label: "Permissions", + label: hasRules ? "Effective Permissions (at last save)" : "Permissions", value: ( {Object.keys(data["Permissions"]) diff --git a/src/components/CippSettings/CippSSOSettings.jsx b/src/components/CippSettings/CippSSOSettings.jsx index 9a2f47829706..a4ae5835429d 100644 --- a/src/components/CippSettings/CippSSOSettings.jsx +++ b/src/components/CippSettings/CippSSOSettings.jsx @@ -15,6 +15,7 @@ import { Table, TableBody, TableCell, + TableContainer, TableHead, TableRow, Typography, @@ -67,32 +68,41 @@ const samPermissionsUsed = [ }, ]; -const PermissionTable = ({ rows, typeLabel }) => ( - - - - Permission - Why it is needed - - - - {rows.map((row) => ( - - - - {row.name} - - - {typeLabel} - - - - {row.reason} - +// Exported for the phone-width overflow story: readable consent text is this table's job. +export const PermissionTable = ({ rows, typeLabel }) => ( + // TableContainer: the surrounding Card sets overflow: hidden, which cut this table off + // with no scroll path — an admin could not read the permission they were asked to approve. + // The monospace names also break, so a phone rarely needs the scrollbar at all. + +
    + + + Permission + Why it is needed - ))} - -
    + + + {rows.map((row) => ( + + + + {row.name} + + + {typeLabel} + + + + {row.reason} + + + ))} + + + ); const statusLabels = { @@ -402,23 +412,23 @@ export const CippSSOSettings = () => { - + Status - + {hasAppId && ( <> - + Admin Consent - + { {data?.appId && ( <> - + App ID - + {data.appId} @@ -456,12 +466,12 @@ export const CippSSOSettings = () => { {signInHosts.length > 0 && ( <> - + Sign-in URLs - + {signInHosts.map((host) => ( { {data?.createdAt && ( <> - + Created - + {new Date(data.createdAt).toLocaleString()} diff --git a/src/components/CippSettings/CippUserManagement.jsx b/src/components/CippSettings/CippUserManagement.jsx index b1d84c2f0b70..5c11abf70345 100644 --- a/src/components/CippSettings/CippUserManagement.jsx +++ b/src/components/CippSettings/CippUserManagement.jsx @@ -217,7 +217,7 @@ export const CippUserManagement = () => { { + if (!value) return null; + const date = new Date(value); + if (isNaN(date.getTime())) return value; + return `${date.toISOString().slice(0, 16).replace("T", " ")} UTC`; +}; const CippVersionProperties = () => { + const [copied, setCopied] = useState(false); + const version = ApiGetCall({ url: "/version.json", queryKey: "LocalVersion", @@ -34,24 +43,78 @@ const CippVersionProperties = () => { ); }; + + const hosting = cippVersion?.data?.Hosting; + const lastUpdate = cippVersion?.data?.LastUpdate; + const lastUpdateText = lastUpdate + ? `v${lastUpdate.PreviousVersion} → v${lastUpdate.NewVersion} (${formatUtc( + lastUpdate.RecordedAt + )})` + : "No update recorded yet"; + + const handleCopy = async () => { + const versionLine = (label, local, remote, outOfDate) => + `${label}: v${local ?? "Unknown"}${outOfDate === true ? ` (v${remote} available)` : ""}`; + const text = [ + versionLine( + "Frontend", + version?.data?.version, + cippVersion?.data?.RemoteCIPPVersion, + cippVersion?.data?.OutOfDateCIPP + ), + versionLine( + "Backend", + cippVersion?.data?.LocalCIPPAPIVersion, + cippVersion?.data?.RemoteCIPPAPIVersion, + cippVersion?.data?.OutOfDateCIPPAPI + ), + `Hosting: ${hosting?.HostingType ?? "Unknown"}`, + `SKU: ${hosting?.SKU ?? "Unknown"}`, + `Runtime: ${hosting?.RuntimeStack ?? "Unknown"}`, + `Last update: ${ + lastUpdate + ? `v${lastUpdate.PreviousVersion} → v${lastUpdate.NewVersion} (${formatUtc( + lastUpdate.RecordedAt + )})` + : "none recorded" + }`, + ].join("\n"); + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error("Failed to copy version info: ", err); + } + }; + return ( { - version.refetch(); - cippVersion.refetch(); - }} - > - - - - Check For Updates - + + + + } title="Version" isFetching={cippVersion.isFetching} @@ -73,7 +136,23 @@ const CippVersionProperties = () => { cippVersion?.data?.OutOfDateCIPPAPI ), }, - ]} + { + label: "Hosting", + value: hosting?.HostingType ?? "Unknown", + }, + { + label: "App Service SKU", + value: hosting?.SKU ?? "Unknown", + }, + { + label: "Runtime Stack", + value: hosting?.RuntimeStack ?? "Unknown", + }, + { + label: "Last Updated", + value: lastUpdateText, + }, + ].map((item) => ({ ...item, sx: { py: 0.5, px: { xs: 2, md: 3 } } }))} /> ); }; diff --git a/src/components/CippStandards/CippStandardAccordion.jsx b/src/components/CippStandards/CippStandardAccordion.jsx index f4414a72ba2e..782d383e6920 100644 --- a/src/components/CippStandards/CippStandardAccordion.jsx +++ b/src/components/CippStandards/CippStandardAccordion.jsx @@ -1154,7 +1154,7 @@ const CippStandardAccordion = ({ ) : ( /* Standard mode layout - original grid layout */ - + {hasAddedComponents && ( - + {/* Add catalog button for Intune Template standard - appears first */} {standardName.startsWith("standards.IntuneTemplate") && ( diff --git a/src/components/CippStandards/CippStandardDialog.jsx b/src/components/CippStandards/CippStandardDialog.jsx index 4761ffcab94f..19690ef6f3f7 100644 --- a/src/components/CippStandards/CippStandardDialog.jsx +++ b/src/components/CippStandards/CippStandardDialog.jsx @@ -1320,7 +1320,7 @@ const CippStandardDialog = ({ {/* Active Filter Chips */} {activeFiltersCount > 0 && ( - + {selectedCategories.map((category) => ( ({ - display: 'flex', - alignItems: 'center', - width: '100%', - maxWidth: '300px', - minWidth: '200px', - height: '40px', - backgroundColor: theme.palette.mode === 'dark' ? '#2A2D3A' : '#F8F9FA', - border: `1px solid ${theme.palette.mode === 'dark' ? '#404040' : '#E0E0E0'}`, - borderRadius: '8px', - padding: '0 12px', - '&:hover': { - borderColor: theme.palette.primary.main, - }, - '&:focus-within': { - borderColor: theme.palette.primary.main, - boxShadow: `0 0 0 2px ${alpha(theme.palette.primary.main, 0.2)}`, - }, - [theme.breakpoints.down('md')]: { - minWidth: '0', - maxWidth: 'none', - flex: 1, - }, -})) - -const ModernSearchInput = styled(InputBase)(({ theme }) => ({ - marginLeft: theme.spacing(1), - flex: 1, - fontSize: '14px', - '& .MuiInputBase-input': { - padding: '8px 0', - '&::placeholder': { - color: theme.palette.text.secondary, - opacity: 0.7, - }, - }, -})) - -const ModernButton = styled(Button)(({ theme }) => ({ - height: '40px', - borderRadius: '8px', - textTransform: 'none', - fontWeight: 500, - fontSize: '14px', - padding: '8px 16px', - backgroundColor: theme.palette.mode === 'dark' ? '#2A2D3A' : '#F8F9FA', - border: `1px solid ${theme.palette.mode === 'dark' ? '#404040' : '#E0E0E0'}`, - color: theme.palette.text.primary, - minWidth: 'auto', - whiteSpace: 'nowrap', - '&:hover': { - backgroundColor: theme.palette.mode === 'dark' ? '#363A4A' : '#F0F0F0', - borderColor: theme.palette.primary.main, - }, - '& .MuiButton-startIcon': { - marginRight: '8px', - }, - '& .MuiButton-endIcon': { - marginLeft: '8px', - }, - [theme.breakpoints.down('md')]: { - padding: '8px 12px', - fontSize: '13px', - '& .MuiButton-startIcon': { - marginRight: '6px', - }, - '& .MuiButton-endIcon': { - marginLeft: '6px', - }, - }, - [theme.breakpoints.down('sm')]: { - padding: '8px 10px', - fontSize: '12px', - '& .MuiButton-startIcon': { - marginRight: '4px', - }, - '& .MuiButton-endIcon': { - marginLeft: '4px', - }, - }, -})) - -const RefreshButton = styled(IconButton)(({ theme }) => ({})) +import { + ModernSearchContainer, + ModernSearchInput, + ModernButton, + RefreshButton, +} from './toolbar-primitives' export const CIPPTableToptoolbar = React.memo( ({ @@ -164,14 +90,42 @@ export const CIPPTableToptoolbar = React.memo( setConfiguredSimpleColumns, queueMetadata, isInDialog = false, + embedded = false, showBulkExportAction = true, + // Mobile card mode: same state, same handlers, different presentation (sheets + // instead of menus). Select-mode state lives in CippDataTable so the card list + // and this toolbar stay in sync. + viewMode = 'table', + selectMode = false, + onSelectModeChange, + selectModeLocked = false, + onViewToggle, + tableViewActive = false, + showReturnToCards = false, + // when set, the selection count + Bulk Actions button portal into this node + // (the Card header's slot) rather than rendering inline in the toolbar + bulkActionsSlot = null, + // Live/Cached data-source controls, rendered in the mobile Table options sheet + dataSourceControls, + // Owned by CippDataTable: this toolbar mounts as two alternating instances (the cards + // branch and the renderTopToolbar branch), so state that must survive the cards<->table + // flip is passed down as props rather than kept in local useState/useRef here. + activeFilters = { graph: null, table: null }, + setActiveFilters, + searchValue = '', + setSearchValue, + restoredFiltersRef, + persistenceKey, + parentRow, }) => { const popover = usePopover() const [filtersAnchor, setFiltersAnchor] = useState(null) const [columnsAnchor, setColumnsAnchor] = useState(null) const [exportAnchor, setExportAnchor] = useState(null) const [actionMenuAnchor, setActionMenuAnchor] = useState(null) - const [searchValue, setSearchValue] = useState('') + const [mobileFilterSheetOpen, setMobileFilterSheetOpen] = useState(false) + // table branch's own handoff instance — the cards branch (CippMobileTableControls) owns a separate one + const mobileFilterSheet = useSheetHandoff(() => setMobileFilterSheetOpen(false)) const mdDown = useMediaQuery((theme) => theme.breakpoints.down('md')) const settings = useSettings() @@ -192,18 +146,15 @@ export const CIPPTableToptoolbar = React.memo( const [originalSimpleColumns, setOriginalSimpleColumns] = useState(simpleColumns) const [filterCanvasVisible, setFilterCanvasVisible] = useState(false) - const [activeFilters, setActiveFilters] = useState({ - graph: null, - table: null, - }) const presetKey = (filter) => filter?.id ?? filter?.filterName - const pageName = router.pathname.split('/').slice(1).join('/') - const currentTenant = settings?.currentTenant + const pageName = persistenceKey ?? (isInDialog ? '' : router.pathname.split('/').slice(1).join('/')) const [useCompactMode, setUseCompactMode] = useState(false) const toolbarRef = useRef(null) const leftContainerRef = useRef(null) const actionsContainerRef = useRef(null) + const wrapActionRow = (original) => attachParentRow(original, parentRow) + const getBulkActions = (actions, selectedRows) => { return ( actions @@ -217,8 +168,8 @@ export const CIPPTableToptoolbar = React.memo( // The default stays all-or-nothing (every selected row must qualify). disabled: action.condition ? action.bulkFilterEligible - ? !selectedRows.some((row) => action.condition(row.original)) - : !selectedRows.every((row) => action.condition(row.original)) + ? !selectedRows.some((row) => action.condition(wrapActionRow(row.original))) + : !selectedRows.every((row) => action.condition(wrapActionRow(row.original))) : false, })) || [] ) @@ -258,42 +209,68 @@ export const CIPPTableToptoolbar = React.memo( }) } - // Track if we've restored filters for this page to prevent infinite loops - const restoredFiltersRef = useRef(new Set()) + // Shared refresh dispatch — desktop refresh button and the mobile filter sheet. + const handleRefresh = () => { + if (typeof refreshFunction === 'object') { + refreshFunction.refetch() + } else if (typeof refreshFunction === 'function') { + refreshFunction() + } else if (data && !getRequestData.isFetched) { + // do nothing because data was sent native. + } else if (getRequestData) { + getRequestData.refetch() + } + } - useEffect(() => { - //if usedData changes, deselect all rows - table.toggleAllRowsSelected(false) - }, [usedData]) + // Shared bulk-action dispatch — desktop bulk menu and the mobile bulk sheet must not + // drift, so both route through here. + const handleBulkAction = (action, closeMenu = () => {}) => { + if (action.disabled) { + return + } - // Sync currentEffectiveQueryKey with queryKey prop changes (e.g., tenant changes) - useEffect(() => { - setCurrentEffectiveQueryKey(queryKey || title) - // Clear active filter name when query key changes (page load, tenant change, etc.) - setActiveFilters({ graph: null, table: null }) - }, [queryKey, title]) + const allSelectedRows = table.getSelectedRowModel().rows + const eligibleRows = + action.bulkFilterEligible && action.condition + ? allSelectedRows.filter((row) => action.condition(wrapActionRow(row.original))) + : allSelectedRows + const selectedData = eligibleRows.map((row) => wrapActionRow(row.original)) + + if (typeof action.customBulkHandler === 'function') { + action.customBulkHandler({ + rows: eligibleRows, + data: selectedData, + closeMenu, + clearSelection: () => table.toggleAllRowsSelected(false), + }) + closeMenu() + return + } - //if the currentTenant Switches, remove Graph filters - useEffect(() => { - if (currentTenant) { - setGraphFilterData({}) - // Clear active filter name when tenant changes - setActiveFilters({ graph: null, table: null }) - // Clear restoration tracking so saved filters can be re-applied - const restorationKey = `${pageName}-graph` - restoredFiltersRef.current.delete(restorationKey) + // Runs before any state change: setting ready:true first mounts CippApiDialog with + // api.noConfirm true, and its mount effect auto-submits into the same customFunction + // being called here — every selected row's action fired twice. + if (action?.noConfirm && action.customFunction) { + eligibleRows.forEach((row) => action.customFunction(wrapActionRow(row.original.original ?? row.original), action, {})) + // Deliberately no closeMenu() here — that matches the behaviour this branch had + // before; the only thing being fixed is the duplicate invocation. + return } - }, [currentTenant, pageName]) - //useEffect to set the column visibility to the preferred columns if they exist + setActionData({ + data: selectedData, + action: action, + ready: true, + }) + createDialog.handleOpen() + closeMenu() + } + + // Sync currentEffectiveQueryKey with queryKey prop changes (e.g., tenant changes) — a + // plain re-derivation from the same props this instance was given, harmless on remount useEffect(() => { - if ( - settings?.columnDefaults?.[pageName] && - Object.keys(settings?.columnDefaults?.[pageName]).length > 0 - ) { - setColumnVisibility(settings?.columnDefaults?.[pageName]) - } - }, [settings?.columnDefaults?.[pageName], router, usedColumns]) + setCurrentEffectiveQueryKey(queryKey || title) + }, [queryKey, title]) useEffect(() => { setOriginalSimpleColumns(simpleColumns) @@ -304,6 +281,7 @@ export const CIPPTableToptoolbar = React.memo( const restorationKey = `${pageName}-graph` if ( + pageName && settings.persistFilters && settings.lastUsedFilters && settings.lastUsedFilters[pageName] && @@ -373,11 +351,6 @@ export const CIPPTableToptoolbar = React.memo( title, ]) - // Clear restoration tracking when page changes - useEffect(() => { - restoredFiltersRef.current.clear() - }, [pageName]) - // Detect overflow and switch to compact mode useEffect(() => { const checkOverflow = () => { @@ -418,16 +391,21 @@ export const CIPPTableToptoolbar = React.memo( usedColumns?.length, ]) - // Restore last used filter on mount if persistFilters is enabled (non-graph filters) + // Restore last used filter on mount if persistFilters is enabled (non-graph filters). + // Once-per-page like the graph slot above: keying this on isFetching used to re-arm the + // 100ms timer on every fetch settle (once per page of an auto-paginated load), clobbering + // whatever filter the user had just applied with the persisted one. useEffect(() => { - // Wait for table to be initialized and data to be available + const restorationKey = `${pageName}-table` + // Wait for table to be initialized and columns to exist (column filters need them) if ( + pageName && settings.persistFilters && settings.lastUsedFilters && settings.lastUsedFilters[pageName] && table && usedColumns.length > 0 && - !getRequestData?.isFetching + !restoredFiltersRef.current.has(restorationKey) ) { // Use setTimeout to ensure the table is fully rendered const timeoutId = setTimeout(() => { @@ -439,13 +417,17 @@ export const CIPPTableToptoolbar = React.memo( } if (last.type === 'global') { + restoredFiltersRef.current.add(restorationKey) table.setGlobalFilter(last.value) + // Keep the visible search box in sync with the filter it now represents + setSearchValue(typeof last.value === 'string' ? last.value : '') setActiveFilters((prev) => ({ ...prev, table: { id: last.id, name: last.name, type: last.type }, })) } else if (last.type === 'column') { - // Only apply if all filter columns exist in the current table + // Only apply if all filter columns exist in the current table; if they don't + // yet (columns still streaming in), leave unmarked so a later run retries. const allColumns = table.getAllColumns().map((col) => col.id) const filterColumns = Array.isArray(last.value) ? last.value.map((f) => f.id) @@ -454,7 +436,10 @@ export const CIPPTableToptoolbar = React.memo( allColumns.includes(colId) ) if (allExist) { - table.setShowColumnFilters(true) + restoredFiltersRef.current.add(restorationKey) + if (viewMode !== 'cards') { + table.setShowColumnFilters(true) + } table.setColumnFilters(last.value) setActiveFilters((prev) => ({ ...prev, @@ -473,7 +458,7 @@ export const CIPPTableToptoolbar = React.memo( pageName, table, usedColumns, - getRequestData?.isFetching, + viewMode, ]) const presetList = ApiGetCall({ @@ -532,12 +517,14 @@ export const CIPPTableToptoolbar = React.memo( } return updatedVisibility }) - settings.handleUpdate({ - columnDefaults: { - ...settings?.columnDefaults, - [pageName]: {}, - }, - }) + if (pageName) { + settings.handleUpdate({ + columnDefaults: { + ...settings?.columnDefaults, + [pageName]: {}, + }, + }) + } setColumnsAnchor(null) } @@ -562,12 +549,14 @@ export const CIPPTableToptoolbar = React.memo( } const saveAsPreferedColumns = () => { - settings.handleUpdate({ - columnDefaults: { - ...settings?.columnDefaults, - [pageName]: columnVisibility, - }, - }) + if (pageName) { + settings.handleUpdate({ + columnDefaults: { + ...settings?.columnDefaults, + [pageName]: columnVisibility, + }, + }) + } setColumnsAnchor(null) } @@ -660,7 +649,7 @@ export const CIPPTableToptoolbar = React.memo( } const persistFilterSlots = (updater) => { - if (!settings.persistFilters || !settings.setLastUsedFilter) { + if (!pageName || !settings.persistFilters || !settings.setLastUsedFilter) { return } const current = normalizePersistedFilters( @@ -674,6 +663,12 @@ export const CIPPTableToptoolbar = React.memo( if (activeFilters.table?.type === 'column') { table.resetColumnFilters() } + // The search box IS the global filter's visible form — a pending debounced + // keystroke or stale text would silently overwrite this preset otherwise. + if (searchDebounceRef.current) { + clearTimeout(searchDebounceRef.current) + } + setSearchValue(typeof filter === 'string' ? filter : '') table.setGlobalFilter(filter) setActiveFilters((prev) => ({ ...prev, @@ -696,8 +691,15 @@ export const CIPPTableToptoolbar = React.memo( if (filterType === 'column') { if (activeFilters.table?.type === 'global') { table.resetGlobalFilter() + if (searchDebounceRef.current) { + clearTimeout(searchDebounceRef.current) + } + setSearchValue('') + } + if (viewMode !== 'cards') { + // Card view renders no header row for the filter inputs to appear in + table.setShowColumnFilters(true) } - table.setShowColumnFilters(true) table.setColumnFilters(filter) setActiveFilters((prev) => ({ ...prev, @@ -801,6 +803,10 @@ export const CIPPTableToptoolbar = React.memo( if (layer === 'table') { if (activeFilters.table?.type === 'global') { table.resetGlobalFilter() + if (searchDebounceRef.current) { + clearTimeout(searchDebounceRef.current) + } + setSearchValue('') } else { table.resetColumnFilters() } @@ -818,6 +824,23 @@ export const CIPPTableToptoolbar = React.memo( } } + // Pages that compute `filters` asynchronously (or swap them per tenant) need the preset + // list to follow the prop — state-only init froze it at first render. Deep-equal via + // JSON: the prop is usually a fresh array literal every render. + const filtersJson = JSON.stringify(filters ?? []) + useEffect(() => { + const propFilters = JSON.parse(filtersJson) + setFilterList((prev) => { + const fetchedGraphPresets = (prev ?? []).filter( + (f) => + f.type === 'graph' && + !propFilters.some((p) => presetKey(p) === presetKey(f)) + ) + return [...propFilters, ...fetchedGraphPresets] + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filtersJson]) + useEffect(() => { if (api?.url === '/api/ListGraphRequest' && presetList.isSuccess) { var endpoint = api?.data?.Endpoint?.replace(/^\//, '') @@ -882,8 +905,117 @@ export const CIPPTableToptoolbar = React.memo( ) + // count + button share this gate whether rendered inline or portaled into the header + const bulkActionsContent = ( + <> + {(table.getIsAllRowsSelected() || table.getIsSomeRowsSelected()) && ( + + {table.getSelectedRowModel().rows.length} rows selected + + )} + + {showBulkActionsButton && ( + + )} + + ) + + // feeds both CippMobileTableControls (cards) and CippTableFilterSheet (table branch) + const mobileColumnItems = table + .getAllColumns() + .filter((column) => !column.id.startsWith('mrt-')) + .map((column) => ({ + id: column.id, + visible: Boolean(column.getIsVisible()), + })) + const handleToggleColumn = (columnId, visible) => + setColumnVisibility({ ...columnVisibility, [columnId]: !visible }) + const handleExportCsvClick = () => + document.querySelector(`[data-csv-export="${title}"]`)?.click() + const handleExportPdfClick = () => + document.querySelector(`[data-pdf-export="${title}"]`)?.click() + const handleViewApiResponse = () => + isInDialog ? setJsonDialogOpen(true) : setOffcanvasVisible(true) + const handleEditGraphFilters = + api?.url === '/api/ListGraphRequest' ? () => setFilterCanvasVisible(true) : undefined + const handleResetFilters = () => setTableFilter('', 'reset', '') + const mobileIsRefreshing = Boolean( + getRequestData?.isFetching || refreshFunction?.isFetching + ) + return ( <> + {viewMode === 'cards' ? ( + + ) : undefined + } + dataSourceControls={dataSourceControls} + /> + ) : ( + <> - {/* Refresh Button */} - - - { - if (typeof refreshFunction === 'object') { - refreshFunction.refetch() - } else if (typeof refreshFunction === 'function') { - refreshFunction() - } else if (data && !getRequestData.isFetched) { - // do nothing because data was sent native. - } else if (getRequestData) { - getRequestData.refetch() + {/* phones refresh from the options sheet instead */} + {!mdDown && ( + + + - - {getRequestData?.isFetchNextPageError ? ( - - ) : ( - - )} - - - - + + {getRequestData?.isFetchNextPageError ? ( + + ) : ( + + )} + + + + + )} {/* Search Input */} @@ -1071,8 +1195,8 @@ export const CIPPTableToptoolbar = React.memo( )} - {/* Mobile/Compact Action Button */} - {(mdDown || useCompactMode) && !hasSelection && ( + {/* Compact Action Button — desktop compact mode only, the phone table uses the filter sheet */} + {!mdDown && useCompactMode && !hasSelection && ( setActionMenuAnchor(event.currentTarget)} sx={{ flexShrink: 0 }} @@ -1081,7 +1205,50 @@ export const CIPPTableToptoolbar = React.memo( )} - {/* Mobile Action Menu */} + {/* phones keep the kebab open regardless of selection, the only route to the + sheet (refresh, export, rows-per-page) down there, not just filters */} + {(mdDown || (useCompactMode && !hasSelection)) && ( + { + if (mdDown) { + setMobileFilterSheetOpen(true) + return + } + setFiltersAnchor(event.currentTarget) + }} + sx={{ + flexShrink: 0, + ...(mdDown && activeSlotCount > 0 && { color: 'primary.main' }), + }} + > + {mdDown ? ( + + + + ) : ( + + )} + + )} + + {/* way back to cards, far right to match the card bar's toggle position */} + {tableViewActive && showReturnToCards && ( + + + + {/* destination icon: tapping here returns to cards */} + + + + + )} + + {/* Compact Action Menu — desktop compact mode only */} + {/* Anchor the nested menus to the stable overflow IconButton — anchoring to + event.currentTarget here targets a MenuItem inside a menu that closes in + the same tick, which positions the next popover unpredictably. */} { - setFiltersAnchor(event.currentTarget) - setActionMenuAnchor(null) - }} - > - - - - Filters - - { - setColumnsAnchor(event.currentTarget) + onClick={() => { + setColumnsAnchor(actionMenuAnchor) setActionMenuAnchor(null) }} > @@ -1118,8 +1277,8 @@ export const CIPPTableToptoolbar = React.memo( {exportEnabled && ( { - setExportAnchor(event.currentTarget) + onClick={() => { + setExportAnchor(actionMenuAnchor) setActionMenuAnchor(null) }} > @@ -1355,46 +1514,8 @@ export const CIPPTableToptoolbar = React.memo( mt: { xs: 1, md: 0 }, }} > - {/* Selected rows indicator */} - {(table.getIsAllRowsSelected() || - table.getIsSomeRowsSelected()) && ( - - {table.getSelectedRowModel().rows.length} rows selected - - )} - - {/* Bulk Actions - inline with toolbar */} - {showBulkActionsButton && ( - - )} + {/* Selected rows indicator + Bulk Actions - inline, unless portaled into the header */} + {!bulkActionsSlot && bulkActionsContent} {/* Queue tracker */} - {/* Hidden export buttons for triggering */} - - - - + + table.setPageSize(size)} + pageSizeOptions={[25, 50, 100, 250, 500]} + dataSourceControls={dataSourceControls} + /> + + )} + + {bulkActionsSlot && createPortal(bulkActionsContent, bulkActionsSlot)} + + {/* Hidden export buttons for triggering — outside the mode branch so the + mobile filter sheet's export items can click them too */} + + + {/* Bulk Actions Menu - now inline with toolbar */} @@ -1446,46 +1598,7 @@ export const CIPPTableToptoolbar = React.memo( { - if (action.disabled) { - return - } - - const allSelectedRows = table.getSelectedRowModel().rows - const selectedRows = - action.bulkFilterEligible && action.condition - ? allSelectedRows.filter((row) => - action.condition(row.original) - ) - : allSelectedRows - const selectedData = selectedRows.map((row) => row.original) - - if (typeof action.customBulkHandler === 'function') { - action.customBulkHandler({ - rows: selectedRows, - data: selectedData, - closeMenu: popover.handleClose, - clearSelection: () => table.toggleAllRowsSelected(false), - }) - popover.handleClose() - return - } - - setActionData({ - data: selectedData, - action: action, - ready: true, - }) - - if (action?.noConfirm && action.customFunction) { - selectedRows.map((row) => - action.customFunction(row.original.original, action, {}) - ) - } else { - createDialog.handleOpen() - popover.handleClose() - } - }} + onClick={() => handleBulkAction(action, popover.handleClose)} > {action.icon} @@ -1525,8 +1638,19 @@ export const CIPPTableToptoolbar = React.memo( fields={actionData.action?.fields} api={actionData.action} row={actionData.data} - relatedQueryKeys={queryKeys} {...actionData.action} + relatedQueryKeys={[ + ...(queryKeys + ? Array.isArray(queryKeys) + ? queryKeys + : [queryKeys] + : []), + ...(Array.isArray(actionData.action?.relatedQueryKeys) + ? actionData.action.relatedQueryKeys + : actionData.action?.relatedQueryKeys + ? [actionData.action.relatedQueryKeys] + : []), + ].filter(Boolean)} /> )} @@ -1538,6 +1662,7 @@ export const CIPPTableToptoolbar = React.memo( onClose={() => setFilterCanvasVisible(!filterCanvasVisible)} contentPadding={1} keepMounted={true} + aboveModal={isInDialog} > { @@ -80,6 +95,41 @@ const compareNullable = (aVal, bVal) => { return aVal > bVal ? 1 : -1 } +// walk up from the card surface to the page's scrolling ancestor (LayoutContainer, +// overflowY auto) and align the surface with its top. the table flips in at the same +// page slot, so the height measurement taken right after reads a deterministic top +// and pages with content above the table keep the table in view +const scrollNodeToScrollableAncestorTop = (node) => { + if (!node) { + return + } + let ancestor = node.parentElement + while (ancestor && ancestor !== document.body) { + const overflowY = window.getComputedStyle(ancestor).overflowY + if (overflowY === 'auto' || overflowY === 'scroll') { + ancestor.scrollTop += node.getBoundingClientRect().top - ancestor.getBoundingClientRect().top + return + } + ancestor = ancestor.parentElement + } + window.scrollTo(0, window.scrollY + node.getBoundingClientRect().top) +} + +/** + * Column order for a user-curated selection: the selected ids first, in selection order, + * then everything else in its existing order. Exported for tests. + * + * MRT only reads initialState.columnOrder once, so when the graph filter swaps in a new + * \$select list after mount, the new columns (dot-delimited nested fields included) were + * appended last — and the card view's three detail slots are filled in column order, so a + * field the user explicitly selected was exactly the one that overflowed into "+N more". + */ +export const orderColumnsBySelection = (allIds, selectedIds) => { + const selected = selectedIds.filter((id) => allIds.includes(id)) + const rest = allIds.filter((id) => !selected.includes(id)) + return [...selected, ...rest] +} + // ── Module-level constants ────────────────────────────────────────────────── // These never change between renders, so extracting them avoids creating new // object references on every render cycle. @@ -88,6 +138,31 @@ const compareNullable = (aVal, bVal) => { // and loop the static-data sync effect. const EMPTY_ARRAY = [] +const buildSubTableColumn = (sub) => ({ + id: sub.id, + header: sub.header ?? sub.id, + size: sub.size ?? 120, + minSize: sub.minSize ?? 100, + enableSorting: false, + enableColumnFilter: false, + enableGlobalFilter: false, + accessorFn: (row) => { + if (typeof sub.label === 'function') { + return sub.label(row) + } + return sub.label ?? 'View' + }, + Cell: ({ row }) => ( + + ), +}) + const SORTING_FNS = { dateTimeNullsLast: (a, b, id) => { const aRaw = getRowValueByColumnId(a, id) @@ -388,6 +463,12 @@ export const CippDataTable = (props) => { defaultSorting = [], isInDialog = false, showBulkExportAction = true, + viewMode: viewModeProp, + mobileCard, + dataSourceControls, + subTables = EMPTY_ARRAY, + persistenceKey, + parentRow, } = props // Create a map of column IDs to their filterType for quick lookup @@ -416,10 +497,10 @@ export const CippDataTable = (props) => { useState(simpleColumns) const [usedData, setUsedData] = useState(data) const [usedColumns, setUsedColumns] = useState([]) + const lastOrderedSelectionRef = useRef(null) const [offcanvasVisible, setOffcanvasVisible] = useState(false) const [offCanvasData, setOffCanvasData] = useState({}) const [offCanvasRowIndex, setOffCanvasRowIndex] = useState(0) - const [filteredRows, setFilteredRows] = useState([]) const [customComponentData, setCustomComponentData] = useState({}) const [customComponentVisible, setCustomComponentVisible] = useState(false) const [actionData, setActionData] = useState({ @@ -432,7 +513,41 @@ export const CippDataTable = (props) => { const [columnFilters, setColumnFilters] = useState([]) const waitingBool = api?.url ? true : false + // The cards branch and the renderTopToolbar branch are two alternating CIPPTableToptoolbar + // instances (only one is ever mounted), so state that must survive the cards<->table flip + // lives here and is passed down as props to both. + const [activeFilters, setActiveFilters] = useState({ graph: null, table: null }) + const [searchValue, setSearchValue] = useState('') + const restoredFiltersRef = useRef(new Set()) + const settings = useSettings() + const router = useRouter() + const routerPageName = router.pathname.split('/').slice(1).join('/') + const pageName = persistenceKey ?? (isInDialog ? '' : routerPageName) + + // 'cards' below the md breakpoint (or when forced via settings/prop), 'table' otherwise. + // simple tables always resolve to 'table'. + const resolvedViewMode = useTableViewMode({ viewMode: viewModeProp, simple }) + // same pivot as the cards/table auto mode, so the FAB and the card list agree on width + const isNarrowViewport = useIsNarrowForTables() + // viewMode prop or simple is a hard force, the toggle never overrides it + const toggleAllowed = !viewModeProp && !simple + // Mobile select mode: checkboxes on cards + the bottom bulk bar. Lives here so the + // toolbar (which renders the Select toggle) and the card list stay in sync. Picker + // tables (onChange) force it on — selection is their entire purpose. + const [mobileSelectMode, setMobileSelectMode] = useState(false) + // portal target for the header's bulk-actions slot, set by the CardHeader's ref callback + const [headerBulkSlot, setHeaderBulkSlot] = useState(null) + + // transient cards<->table override, session-only, never persisted + const [viewOverride, setViewOverride] = useState(null) + const effectiveViewMode = toggleAllowed ? (viewOverride ?? resolvedViewMode) : resolvedViewMode + const isCardView = effectiveViewMode === 'cards' + const tableViewActive = effectiveViewMode === 'table' + const CardViewSurface = noCard ? Box : Card + const [narrowTableMaxHeight, setNarrowTableMaxHeight] = useState(null) + // way back button: table is only up because of the override, phone default is still cards + const showReturnToCards = toggleAllowed && tableViewActive && resolvedViewMode === 'cards' // Hook to trigger re-render when license backfill completes const { updateTrigger } = useLicenseBackfill() @@ -578,8 +693,12 @@ export const CippDataTable = (props) => { }) } else if (configuredSimpleColumns.length > 0) { // Resolve any variables in the simple columns before checking visibility - const resolvedSimpleColumns = resolveSimpleColumnVariables( - configuredSimpleColumns, + const resolvedSimpleColumns = resolveSubTableSimpleColumns( + resolveSimpleColumnVariables( + configuredSimpleColumns, + usedData + ), + subTables, usedData ) @@ -595,6 +714,25 @@ export const CippDataTable = (props) => { newVisibility[col.id] = finalResolvedColumns.includes(col.id) } }) + // Selection order wins over data-key order — but only when the resolved selection + // changed (including subTable cachedColumn swaps), so a data refetch doesn't + // stomp a manual column reorder. + const resolvedOrderKey = finalResolvedColumns.join('|') + if (lastOrderedSelectionRef.current !== resolvedOrderKey) { + lastOrderedSelectionRef.current = resolvedOrderKey + const subTableIds = getSubTableDisplayColumnIds( + subTables, + configuredSimpleColumns, + usedData + ) + const allIds = [ + ...new Set([ + ...finalColumns.map((col) => col.id).filter(Boolean), + ...subTableIds, + ]), + ] + table.setColumnOrder(orderColumnsBySelection(allIds, finalResolvedColumns)) + } } else { const providedColumnKeys = new Set( columns.map((col) => col.id || col.header) @@ -630,8 +768,68 @@ export const CippDataTable = (props) => { queryKey, settings?.currentTenant, filterTypeMap, + subTables, ]) + // Previous-value refs for the guards below: CippDataTable is the single owner of this + // state across both toolbar instances, so an effect can compare against the last value + // it actually saw rather than firing unconditionally on every render. + const prevTenantRef = useRef(settings?.currentTenant) + const prevQueryKeyRef = useRef(queryKey || title) + const prevPageNameRef = useRef(pageName) + const appliedColumnDefaultsRef = useRef({}) + + // if the currentTenant switches, remove graph filters and the active-filter highlight + useEffect(() => { + const currentTenant = settings?.currentTenant + if (prevTenantRef.current === currentTenant) { + return + } + prevTenantRef.current = currentTenant + if (currentTenant) { + setGraphFilterData({}) + setActiveFilters({ graph: null, table: null }) + restoredFiltersRef.current.delete(`${pageName}-graph`) + } + }, [settings?.currentTenant, pageName]) + + // clear the active-filter highlight when the effective query key changes (tenant swap, + // different queryKey/title) + useEffect(() => { + const effectiveKey = queryKey || title + if (prevQueryKeyRef.current === effectiveKey) { + return + } + prevQueryKeyRef.current = effectiveKey + setActiveFilters({ graph: null, table: null }) + }, [queryKey, title]) + + // clear persisted-filter restoration tracking only when the page actually changes + useEffect(() => { + if (prevPageNameRef.current === pageName) { + return + } + prevPageNameRef.current = pageName + restoredFiltersRef.current.clear() + }, [pageName]) + + // apply preferred columns once per page, and again whenever the saved preference's + // identity changes. Nested dialog tables must not read or write the parent page key. + useEffect(() => { + if (!pageName) { + return + } + const preferred = settings?.columnDefaults?.[pageName] + if ( + preferred && + Object.keys(preferred).length > 0 && + appliedColumnDefaultsRef.current[pageName] !== preferred + ) { + appliedColumnDefaultsRef.current[pageName] = preferred + setColumnVisibility(preferred) + } + }, [settings?.columnDefaults?.[pageName], pageName]) + const createDialog = useDialog() const hasActions = !!actions const hasOffCanvas = !!offCanvas @@ -648,7 +846,9 @@ export const CippDataTable = (props) => { offCanvas, onChange, maxHeightOffset, - settings + settings, + effectiveViewMode, + isNarrowViewport ), [ simple, @@ -657,6 +857,8 @@ export const CippDataTable = (props) => { hasOnChange, maxHeightOffset, settings?.tablePageSize?.value, + effectiveViewMode, + isNarrowViewport, ] ) @@ -678,12 +880,70 @@ export const CippDataTable = (props) => { return result }, [columnVisibility]) + const displayColumns = useMemo(() => { + if (!Array.isArray(subTables) || subTables.length === 0) { + return usedColumns + } + const cachedHeaders = new Map() + const injected = [] + for (const sub of subTables) { + if (!subTableIsSelected(sub, configuredSimpleColumns)) { + continue + } + if (subTableShowsCachedColumn(sub, usedData)) { + cachedHeaders.set(sub.cachedColumn, sub.header ?? sub.id) + continue + } + injected.push(buildSubTableColumn(sub)) + } + const columns = cachedHeaders.size + ? usedColumns.map((col) => + cachedHeaders.has(col.id) + ? { ...col, header: cachedHeaders.get(col.id) } + : col + ) + : usedColumns + const injectedById = new Map(injected.map((col) => [col.id, col])) + const replaced = columns.map((col) => injectedById.get(col.id) ?? col) + const existing = new Set(columns.map((col) => col.id)) + return [...replaced, ...injected.filter((col) => !existing.has(col.id))] + }, [usedColumns, usedData, subTables, configuredSimpleColumns]) + + useEffect(() => { + if (!Array.isArray(subTables) || subTables.length === 0) { + return + } + setColumnVisibility((prev) => { + const next = { ...prev } + let changed = false + for (const sub of subTables) { + if (!subTableIsSelected(sub, configuredSimpleColumns)) { + continue + } + const columnId = subTableShowsCachedColumn(sub, usedData) + ? sub.cachedColumn + : sub.id + if (next[columnId] === undefined) { + next[columnId] = true + changed = true + } + } + return changed ? next : prev + }) + }, [subTables, configuredSimpleColumns, usedData]) + const handleActionDisabled = useCallback((row, action) => { + const actionRow = attachParentRow(row, parentRow) if (action?.condition) { - return !action.condition(row) + return !action.condition(actionRow) } return false - }, []) + }, [parentRow]) + + const getActionRow = useCallback( + (rowOriginal) => attachParentRow(rowOriginal, parentRow), + [parentRow] + ) // Stable callback for sorting changes. const handleSortingChange = useCallback((newSorting) => { @@ -733,12 +993,12 @@ export const CippDataTable = (props) => { } setOffCanvasData(row.original) - const filteredRowsArray = table?.getFilteredRowModel?.()?.rows - if (filteredRowsArray) { - const indexInFiltered = filteredRowsArray.findIndex( + const navigable = table?.getSortedRowModel?.()?.rows + if (navigable) { + const indexInList = navigable.findIndex( (r) => r.original === row.original ) - setOffCanvasRowIndex(indexInFiltered >= 0 ? indexInFiltered : 0) + setOffCanvasRowIndex(indexInList >= 0 ? indexInList : 0) } setOffcanvasVisible(true) }, @@ -758,7 +1018,7 @@ export const CippDataTable = (props) => { const renderEmptyRowsFallback = useCallback( ({ table }) => queueMessage ? ( - +
    {queueMessage}
    @@ -785,6 +1045,72 @@ export const CippDataTable = (props) => { [sanitizedColumnVisibility, sorting, columnFilters, showSkeletons] ) + // Single row-action dispatch used by BOTH the desktop row menu and the mobile action + // sheet — the two presentations must not drift. + // `table` is referenced via closure: it is declared below but initialized before any + // handler can run (the same pattern the row menu has always relied on). + const dispatchRowAction = useCallback( + (action, rowOriginal, closeMenu = () => {}) => { + const scopeToRowTenant = () => { + const tenant = getRowTenant(getActionRow(rowOriginal), settings.currentTenant) + if (settings.currentTenant === 'AllTenants' && tenant && tenant !== 'AllTenants') { + settings.handleUpdate({ + currentTenant: tenant, + }) + } + } + + if (action.noConfirm && action.customFunction) { + scopeToRowTenant() + action.customFunction(getActionRow(rowOriginal), action, {}) + closeMenu() + return + } + + // Handle custom component differently + if (typeof action.customComponent === 'function') { + scopeToRowTenant() + setCustomComponentData({ data: getActionRow(rowOriginal), action: action }) + setCustomComponentVisible(true) + closeMenu() + return + } + + // Standard dialog flow + setActionData({ + data: getActionRow(rowOriginal), + action: action, + ready: true, + }) + createDialog.handleOpen() + closeMenu() + }, + [settings, createDialog, getActionRow] + ) + + // Open the extended-info offcanvas for a row, recording its position in the row model so + // prev/next navigation works. Shared by the row menu, the mobile action sheet, and card + // taps. The SORTED model is the one on screen — the filtered model is pre-sort, so a + // position taken from it stops matching the list the moment a column is sorted. + const openRowOffCanvas = useCallback((rowOriginal) => { + setOffCanvasData(rowOriginal) + const navigable = table.getSortedRowModel().rows + const indexInList = navigable.findIndex((r) => r.original === rowOriginal) + setOffCanvasRowIndex(indexInList >= 0 ? indexInList : 0) + setOffcanvasVisible(true) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // the flipped table shows whatever columns are visible; horizontal scroll covers the width + const cardViewSurfaceRef = useRef(null) + const handleViewToggle = useCallback(() => { + const nextView = effectiveViewMode === 'table' ? 'cards' : 'table' + setViewOverride(nextView) + if (nextView === 'table' && isNarrowViewport) { + scrollNodeToScrollableAncestorTop(cardViewSurfaceRef.current) + } + }, [effectiveViewMode, isNarrowViewport]) + // Memoize renderRowActionMenuItems to avoid re-creating on each render. const renderRowActionMenuItems = useMemo(() => { if (actions) { @@ -795,49 +1121,13 @@ export const CippDataTable = (props) => { // condition, which renders it disabled). (action) => typeof action.hideCondition !== 'function' || - !action.hideCondition(row.original) + !action.hideCondition(getActionRow(row.original)) ) .map((action, index) => ( { - const scopeToRowTenant = () => { - if ( - settings.currentTenant === 'AllTenants' && - row.original?.Tenant - ) { - settings.handleUpdate({ - currentTenant: row.original.Tenant, - }) - } - } - - if (action.noConfirm && action.customFunction) { - scopeToRowTenant() - action.customFunction(row.original, action, {}) - closeMenu() - return - } - - // Handle custom component differently - if (typeof action.customComponent === 'function') { - scopeToRowTenant() - setCustomComponentData({ data: row.original, action: action }) - setCustomComponentVisible(true) - closeMenu() - return - } - - // Standard dialog flow - setActionData({ - data: row.original, - action: action, - ready: true, - }) - createDialog.handleOpen() - closeMenu() - }} + onClick={() => dispatchRowAction(action, row.original, closeMenu)} disabled={handleActionDisabled(row.original, action)} > @@ -851,14 +1141,7 @@ export const CippDataTable = (props) => { key={`actions-list-row-more`} onClick={() => { closeMenu() - setOffCanvasData(row.original) - // Find the index of this row in the filtered rows - const filteredRowsArray = table.getFilteredRowModel().rows - const indexInFiltered = filteredRowsArray.findIndex( - (r) => r.original === row.original - ) - setOffCanvasRowIndex(indexInFiltered >= 0 ? indexInFiltered : 0) - setOffcanvasVisible(true) + openRowOffCanvas(row.original) }} > @@ -875,13 +1158,7 @@ export const CippDataTable = (props) => { { closeMenu() - setOffCanvasData(row.original) - const filteredRowsArray = table.getFilteredRowModel().rows - const indexInFiltered = filteredRowsArray.findIndex( - (r) => r.original === row.original - ) - setOffCanvasRowIndex(indexInFiltered >= 0 ? indexInFiltered : 0) - setOffcanvasVisible(true) + openRowOffCanvas(row.original) }} > @@ -896,9 +1173,10 @@ export const CippDataTable = (props) => { }, [ actions, offCanvas, - settings.currentTenant, + dispatchRowAction, + openRowOffCanvas, handleActionDisabled, - createDialog, + getActionRow, ]) // Stable renderTopToolbar — memoized so MaterialReactTable doesn't re-create the toolbar @@ -916,8 +1194,8 @@ export const CippDataTable = (props) => { data={data} columnVisibility={columnVisibility} getRequestData={getRequestData} - usedColumns={usedColumns} - usedData={memoizedData ?? []} + usedColumns={displayColumns} + usedData={memoizedData ?? EMPTY_ARRAY} title={title} actions={actions} exportEnabled={exportEnabled} @@ -929,8 +1207,20 @@ export const CippDataTable = (props) => { setGraphFilterData={setGraphFilterData} setConfiguredSimpleColumns={setConfiguredSimpleColumns} queueMetadata={getRequestData.data?.pages?.[0]?.Metadata} + persistenceKey={persistenceKey} + parentRow={parentRow} isInDialog={isInDialog} showBulkExportAction={showBulkExportAction} + onViewToggle={toggleAllowed ? handleViewToggle : undefined} + tableViewActive={toggleAllowed ? tableViewActive : undefined} + showReturnToCards={showReturnToCards} + bulkActionsSlot={isNarrowViewport && !isInDialog ? headerBulkSlot : null} + dataSourceControls={dataSourceControls} + activeFilters={activeFilters} + setActiveFilters={setActiveFilters} + searchValue={searchValue} + setSearchValue={setSearchValue} + restoredFiltersRef={restoredFiltersRef} /> )} @@ -945,6 +1235,7 @@ export const CippDataTable = (props) => { columnVisibility, getRequestData, usedColumns, + displayColumns, memoizedData, title, actions, @@ -954,6 +1245,15 @@ export const CippDataTable = (props) => { graphFilterData, isInDialog, showBulkExportAction, + toggleAllowed, + handleViewToggle, + tableViewActive, + showReturnToCards, + isNarrowViewport, + headerBulkSlot, + dataSourceControls, + activeFilters, + searchValue, ] ) @@ -976,14 +1276,26 @@ export const CippDataTable = (props) => { columnFilters: columnFilters, columnVisibility: sanitizedColumnVisibility, }, - columns: usedColumns, - data: memoizedData ?? [], + columns: displayColumns, + data: memoizedData ?? EMPTY_ARRAY, state: tableState, onSortingChange: handleSortingChange, onColumnFiltersChange: setColumnFilters, renderEmptyRowsFallback, onColumnVisibilityChange: setColumnVisibility, ...modeInfo, + // narrow table views size their scroll viewport from measurement (see the effect below), + // the modeInfo calc budget only holds for desktop chrome + ...(isNarrowViewport && + effectiveViewMode === 'table' && { + muiTableContainerProps: { + ...modeInfo.muiTableContainerProps, + sx: { + ...modeInfo.muiTableContainerProps?.sx, + maxHeight: narrowTableMaxHeight ? `${narrowTableMaxHeight}px` : 'none', + }, + }, + }), renderRowActionMenuItems, renderTopToolbar, sortingFns: SORTING_FNS, @@ -994,29 +1306,173 @@ export const CippDataTable = (props) => { renderColumnFilterModeMenuItems: renderColumnFilterModeMenuItemsFn, }) + // deselect all rows when the underlying data set actually changes, guarded so a toolbar + // remount (cards<->table flip) does not wipe an in-progress selection + const prevUsedDataRef = useRef(memoizedData) + useEffect(() => { + if (prevUsedDataRef.current === memoizedData) { + return + } + prevUsedDataRef.current = memoizedData + table.toggleAllRowsSelected(false) + }, [memoizedData]) + + // utilTableMode seeds columnOrder from simpleColumns (e.g. "members"), but cached report + // data shows membersCsv instead — MRT crashes if order references ids that are not in + // displayColumns. + useEffect(() => { + if (!Array.isArray(subTables) || subTables.length === 0) { + return + } + const displayIds = displayColumns.map((col) => col.id).filter(Boolean) + if (displayIds.length === 0) { + return + } + const currentOrder = table.getState().columnOrder ?? [] + if (!columnOrderHasStaleIds(currentOrder, displayIds)) { + return + } + const selectedForOrder = resolveSubTableSimpleColumns( + configuredSimpleColumns, + subTables, + usedData + ).filter((id) => displayIds.includes(id)) + table.setColumnOrder(orderColumnsBySelection(displayIds, selectedForOrder)) + }, [configuredSimpleColumns, displayColumns, subTables, usedData, table]) + + // size the narrow table's scroll viewport from where it actually sits: viewport height + // minus the container's measured top, the real footer height and the chrome below the + // paper. the desktop calc assumes chrome heights that phone layouts do not have. + // deps include getRequestData.isSuccess so a cold load (table not yet mounted when the + // toggle flips tableViewActive true) re-arms the measurement once MRT actually renders + useEffect(() => { + if (!(isNarrowViewport && tableViewActive)) { + setNarrowTableMaxHeight(null) + return undefined + } + const measure = () => { + const container = table.refs.tableContainerRef?.current + if (!container) { + return + } + const footer = table.refs.bottomToolbarRef?.current?.offsetHeight ?? 0 + // chrome between the paper's bottom edge and the page bottom (CardContent padding + page gap) + const BELOW_PAPER_PX = 40 + // viewport-relative, the toggle handler aligns the card surface with the scroll + // viewport top first so this reads a deterministic position + const top = container.getBoundingClientRect().top + let next = Math.max(240, Math.floor(window.innerHeight - top - footer - BELOW_PAPER_PX)) + // 120 = minimal chrome allowance, keeps the table from claiming the full viewport + next = Math.min(next, window.innerHeight - 120) + setNarrowTableMaxHeight((prev) => { + if (prev !== null && Math.abs(prev - next) <= 1) { + return prev + } + return next + }) + } + const raf = requestAnimationFrame(() => requestAnimationFrame(measure)) + window.addEventListener('resize', measure) + let observer + // the paper mounts in the same commit as the container and the footer, so it is a + // reliable observation target even on the pass where the footer ref is still null + const paper = table.refs.tablePaperRef?.current + if (typeof ResizeObserver !== 'undefined' && paper) { + observer = new ResizeObserver(measure) + observer.observe(paper) + } + return () => { + cancelAnimationFrame(raf) + window.removeEventListener('resize', measure) + observer?.disconnect() + } + }, [isNarrowViewport, tableViewActive, table, getRequestData.isSuccess]) + + // A card shows at most a title, subtitle, three chips and three detail rows, so the rest + // of the row has to live in the drawer. On a page with no offCanvas that means every + // column the user has chosen to show; on a page that configured one, its curated fields + // come first and the remaining visible columns are appended rather than dropped — on + // desktop those columns are still on screen in the table, on mobile they are not. + const cardInfoFields = useMemo(() => { + if (!isCardView) return undefined + // A page that renders its own drawer body (offCanvas.children — the test-detail pages) + // is the authority on what the drawer shows; prepending the generic property list on + // top of it repeated Risk/Status above a body that already presents them. + if (offCanvas?.children) return undefined + const visible = table + .getVisibleLeafColumns() + .map((column) => column.id) + .filter((id) => !id.startsWith('mrt-')) + const curated = offCanvas?.extendedInfoFields + if (!curated?.length) return visible + // Curated order wins; dedupe case-insensitively across both lists, and within the + // curated list itself, so a field can't be shown twice. + const seen = new Set() + return [...curated, ...visible].filter((id) => { + if (typeof id !== 'string') return false + const key = id.toLowerCase() + if (seen.has(key)) return false + seen.add(key) + return true + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [offCanvas, isCardView, table, columnVisibility, usedColumns]) + + // Applied after the {...offCanvas} spread below, which carries the page's own + // extendedInfoFields and would otherwise overwrite the merged list. Card view renders + // them the way the table cells do, since the appended entries are columns. + const cardInfoOverride = + isCardView && cardInfoFields?.length + ? { extendedInfoFields: cardInfoFields, richFormatting: true } + : {} + + // The rows the drawer's Prev/Next walks, in the order they are on screen. Read live from + // the table on every render: this used to be mirrored into state by an effect keyed on + // filters and sorting, so the copy was taken once at mount — before the rows had arrived + // — and every table that loads its data asynchronously reported nothing to navigate. + const navigationRows = table.getSortedRowModel().rows + // Prefer the position of the row actually on show, so sorting or filtering while the + // drawer is open carries the counter with it. The stored index covers the case where the + // row has left the list entirely — a background refetch replaces every object, so + // identity alone would strand the position — and is clamped so a list that shrank under + // it can't report "6 of 2". + const derivedRowIndex = offcanvasVisible + ? navigationRows.findIndex((row) => row.original === offCanvasData) + : -1 + const currentRowIndex = + derivedRowIndex >= 0 + ? derivedRowIndex + : Math.min(offCanvasRowIndex, Math.max(navigationRows.length - 1, 0)) + // Remove the useEffect that was resetting filters on table changes // The initial filter application is now handled by the columnFilters state // and the useEffect above that only triggers on actual filter prop changes + // Exiting mobile select mode clears the selection — "Done" means done. + const handleMobileSelectModeChange = useCallback( + (on) => { + setMobileSelectMode(on) + if (!on) { + table.toggleAllRowsSelected(false) + } + }, + [table] + ) + + // Empty-state "Clear filters" in the card list. The full reset (graph filters, + // persisted slots) lives in the toolbar's filter sheet; this only clears what makes + // the current list empty. + const handleClearAllFilters = useCallback(() => { + table.resetGlobalFilter() + table.resetColumnFilters() + }, [table]) + useEffect(() => { if (onChange && table.getSelectedRowModel().rows) { onChange(table.getSelectedRowModel().rows.map((row) => row.original)) } }, [table.getSelectedRowModel().rows]) - useEffect(() => { - // Update filtered rows whenever table filtering/sorting changes - if (table && table.getFilteredRowModel) { - const rows = table.getFilteredRowModel().rows - setFilteredRows(rows.map((row) => row.original)) - } - }, [ - table, - table.getState().columnFilters, - table.getState().globalFilter, - table.getState().sorting, - ]) - useEffect(() => { //check if the simplecolumns are an array, if (Array.isArray(simpleColumns) && simpleColumns.length > 0) { @@ -1024,9 +1480,126 @@ export const CippDataTable = (props) => { } }, [simpleColumns]) + const selectModeActive = hasOnChange ? true : mobileSelectMode + + const resolvedCardButton = cardButton ? ( + + ) : undefined + + // below md, table-in-Card branch: the actions FAB carries cardButton + const headerAction = isNarrowViewport && !isInDialog ? undefined : resolvedCardButton + return ( <> - {noCard ? ( + {isCardView ? ( + // same paper surface as the table path; overflow visible keeps the controls bar sticky + + {!hideTitle && ( + + + {title} + + {Array.isArray(usedData) && !showSkeletons && ( + + {table.getFilteredRowModel().rows.length} results + + )} + + )} + {!Array.isArray(usedData) && usedData ? ( + + ) : ( + <> + + + + )} + {getRequestData.isError && !getRequestData.isFetchNextPageError && ( + getRequestData.refetch()} + message={`Error Loading data: ${getCippError(getRequestData.error)}`} + /> + )} + + ) : noCard ? ( {!Array.isArray(usedData) && usedData ? ( @@ -1046,40 +1619,61 @@ export const CippDataTable = (props) => { ) : ( // Render the table inside a Card - - {cardButton || !hideTitle ? ( - <> - - - - ) : null} - - - {!Array.isArray(usedData) && usedData ? ( - - ) : ( - <> - {(getRequestData.isSuccess || - getRequestData.data?.pages.length >= 0 || - (data && !getRequestData.isError)) && ( - - )} - - )} - {getRequestData.isError && - !getRequestData.isFetchNextPageError && ( - getRequestData.refetch()} - message={`Error Loading data: ${getCippError(getRequestData.error)}`} - /> + <> + + {cardButton || !hideTitle ? ( + <> + + + {/* narrow viewports carry these in the Table options sheet */} + {dataSourceControls && !isNarrowViewport ? ( + + {dataSourceControls} + {headerAction} + + ) : ( + headerAction + )} + + } + title={hideTitle ? '' : title} + {...props.cardHeaderProps} + /> + + + ) : null} + + + {!Array.isArray(usedData) && usedData ? ( + + ) : ( + <> + {(getRequestData.isSuccess || + getRequestData.data?.pages.length >= 0 || + (data && !getRequestData.isError)) && ( + + )} + )} - - - + {getRequestData.isError && + !getRequestData.isFetchNextPageError && ( + getRequestData.refetch()} + message={`Error Loading data: ${getCippError(getRequestData.error)}`} + /> + )} + + + + {isNarrowViewport && !isInDialog && resolvedCardButton && ( + {resolvedCardButton} + )} + )} { onClose={() => setOffcanvasVisible(false)} extendedData={offCanvasData} extendedInfoFields={offCanvas?.extendedInfoFields} - actions={actions} title={offCanvasData?.Name || offCanvas?.title || 'Extended Info'} + aboveModal={isInDialog} children={ offCanvas?.children - ? (row) => offCanvas.children(row, offCanvasRowIndex) + ? (row) => offCanvas.children(row, currentRowIndex) : undefined } customComponent={offCanvas?.customComponent} onNavigateUp={() => { - const newIndex = offCanvasRowIndex - 1 - if (newIndex >= 0 && filteredRows && filteredRows[newIndex]) { + const newIndex = currentRowIndex - 1 + if (newIndex >= 0 && navigationRows[newIndex]) { setOffCanvasRowIndex(newIndex) - setOffCanvasData(filteredRows[newIndex]) + setOffCanvasData(navigationRows[newIndex].original) } }} onNavigateDown={() => { - const newIndex = offCanvasRowIndex + 1 - if (filteredRows && newIndex < filteredRows.length) { + const newIndex = currentRowIndex + 1 + if (navigationRows[newIndex]) { setOffCanvasRowIndex(newIndex) - setOffCanvasData(filteredRows[newIndex]) + setOffCanvasData(navigationRows[newIndex].original) } }} - canNavigateUp={offCanvasRowIndex > 0} - canNavigateDown={ - filteredRows && offCanvasRowIndex < filteredRows.length - 1 - } + canNavigateUp={currentRowIndex > 0} + canNavigateDown={currentRowIndex < navigationRows.length - 1} + navigationPosition={{ + index: currentRowIndex + 1, + total: navigationRows.length, + }} {...offCanvas} + {...cardInfoOverride} + // Retired: the drawer's action buttons never dispatched reliably, and the row menu + // is the actions surface. Last so it beats page configs carrying offCanvas.actions. + actions={undefined} /> {/* Render custom component */} {customComponentVisible && @@ -1140,8 +1740,15 @@ export const CippDataTable = (props) => { fields={actionData.action?.fields} api={actionData.action} row={actionData.data} - relatedQueryKeys={queryKey ? queryKey : title} {...actionData.action} + relatedQueryKeys={[ + ...(queryKey ? [queryKey] : title ? [title] : []), + ...(Array.isArray(actionData.action?.relatedQueryKeys) + ? actionData.action.relatedQueryKeys + : actionData.action?.relatedQueryKeys + ? [actionData.action.relatedQueryKeys] + : []), + ].filter(Boolean)} /> ) }, [ diff --git a/src/components/CippTable/CippDataTableButton.jsx b/src/components/CippTable/CippDataTableButton.jsx index 86c3c887e1e9..9c99484e72e9 100644 --- a/src/components/CippTable/CippDataTableButton.jsx +++ b/src/components/CippTable/CippDataTableButton.jsx @@ -1,11 +1,45 @@ -import { useState } from "react"; -import { Dialog, DialogContent, Button } from "@mui/material"; +import { useMemo, useState } from "react"; +import { Dialog, DialogContent, DialogTitle, IconButton, Button, useMediaQuery } from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; import { CippDataTable } from "./CippDataTable"; import { getCippTranslation } from "../../utils/get-cipp-translation"; -const CippDataTableButton = ({ data, title, tableTitle = "Data" }) => { +import { resolveRowTemplates, getRowTenant } from "../../utils/resolve-row-templates"; +import { useSettings } from "../../hooks/use-settings"; + +const applyTenantFilterDefault = (api, row, currentTenant) => { + if (!api) { + return api; + } + const data = { ...(api.data || {}) }; + if (data.tenantFilter === undefined && data.TenantFilter === undefined) { + const tenant = getRowTenant(row, currentTenant); + if (tenant) { + data.tenantFilter = tenant; + } + } + return { ...api, data }; +}; + +const CippDataTableButton = ({ + data, + title, + tableTitle = "Data", + row, + api, + label, + condition, + queryKey, + ...tableProps +}) => { const [openDialogs, setOpenDialogs] = useState([]); + const [liveOpen, setLiveOpen] = useState(false); + const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md")); + const settings = useSettings(); + const isLive = Boolean(api?.url); + + const nestedTitle = tableProps.title ?? tableTitle ?? title ?? "Data"; - const handleOpenDialog = (event) => { + const handleOpenStaticDialog = (event) => { event?.stopPropagation(); let dataArray; @@ -23,10 +57,49 @@ const CippDataTableButton = ({ data, title, tableTitle = "Data" }) => { setOpenDialogs([...openDialogs, dataArray]); }; - const handleCloseDialog = (index, event) => { + const handleCloseStaticDialog = (index, event) => { event?.stopPropagation?.(); setOpenDialogs(openDialogs.filter((_, i) => i !== index)); }; + + const handleOpenLiveDialog = (event) => { + event?.stopPropagation(); + setLiveOpen(true); + }; + + const handleCloseLiveDialog = (event) => { + event?.stopPropagation?.(); + setLiveOpen(false); + }; + + const liveTableProps = useMemo(() => { + if (!isLive || !liveOpen) { + return null; + } + const templatedApi = applyTenantFilterDefault( + resolveRowTemplates(api, row), + row, + settings?.currentTenant + ); + const templatedQueryKey = queryKey + ? resolveRowTemplates(queryKey, row) + : undefined; + const templatedTitle = resolveRowTemplates(nestedTitle, row); + const { title: _ignoredTitle, ...rest } = tableProps; + + return { + ...rest, + api: templatedApi, + queryKey: templatedQueryKey, + title: templatedTitle, + parentRow: row, + isInDialog: true, + simple: rest.simple ?? false, + hideTitle: mdDown, + maxHeightOffset: rest.maxHeightOffset ?? "160px", + }; + }, [api, isLive, liveOpen, mdDown, nestedTitle, queryKey, row, settings?.currentTenant, tableProps]); + const dataIsNotANullArray = !Array.isArray(data) && (typeof data !== "object" || data === null || Object.keys(data).length === 0); @@ -36,38 +109,99 @@ const CippDataTableButton = ({ data, title, tableTitle = "Data" }) => { ? Object.keys(data).length : 0; + const liveDisabled = typeof condition === "function" ? !condition(row) : false; + const buttonLabel = isLive + ? typeof label === "function" + ? label(row) + : label ?? "View" + : dataIsNotANullArray + ? "No items" + : `${dataLength} items`; + + const dialogTitle = isLive + ? liveTableProps?.title ?? nestedTitle + : tableTitle; + return ( <> - {openDialogs.map((dialogData, index) => ( + {isLive && liveOpen && liveTableProps && ( handleCloseDialog(index, event)} + onClose={handleCloseLiveDialog} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} fullWidth + fullScreen={mdDown} maxWidth="lg" > - - + {mdDown && ( + + + + + {dialogTitle} + + )} + + - ))} + )} + + {!isLive && + openDialogs.map((dialogData, index) => ( + handleCloseStaticDialog(index, event)} + onMouseDown={(event) => event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + fullWidth + fullScreen={mdDown} + maxWidth="lg" + > + {mdDown && ( + + handleCloseStaticDialog(index, event)} + aria-label="Close" + sx={{ minWidth: 44, minHeight: 44 }} + > + + + {tableTitle} + + )} + + + + + ))} ); }; diff --git a/src/components/CippTable/CippDiagnosticsFilter.js b/src/components/CippTable/CippDiagnosticsFilter.js index e8118a10e090..3eeb44cf002f 100644 --- a/src/components/CippTable/CippDiagnosticsFilter.js +++ b/src/components/CippTable/CippDiagnosticsFilter.js @@ -19,8 +19,11 @@ import { CippFormComponent } from "../CippComponents/CippFormComponent"; import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; import { Grid } from "@mui/system"; import defaultPresets from "../../data/DiagnosticsPresets.json"; +import { useIsMobileLayout } from "../../hooks/use-breakpoint"; const CippDiagnosticsFilter = ({ onSubmitFilter }) => { + // A 12-row monospace query box is roughly half a phone viewport before anything else. + const isMobile = useIsMobileLayout(); const [expanded, setExpanded] = useState(true); const [selectedPreset, setSelectedPreset] = useState(null); const [presetOptions, setPresetOptions] = useState([]); @@ -270,7 +273,7 @@ const CippDiagnosticsFilter = ({ onSubmitFilter }) => { label="KQL Query" formControl={formControl} multiline - rows={12} + rows={isMobile ? 6 : 12} placeholder={`Enter your KQL query here, for example:\n\ntraces\n| where timestamp > ago(1h)\n| where severityLevel >= 2\n| project timestamp, message, severityLevel\n| order by timestamp desc`} helperText="Enter a valid Kusto Query Language (KQL) query to execute against Application Insights" sx={{ @@ -281,7 +284,7 @@ const CippDiagnosticsFilter = ({ onSubmitFilter }) => { }} /> - + - + - + @@ -171,7 +180,7 @@ const CippGraphExplorerSimpleFilter = ({ variant="outlined" startIcon={} onClick={() => setOffCanvasVisible(true)} - sx={{ minWidth: "120px" }} + sx={{ minWidth: { md: "120px" } }} > Edit Query @@ -180,7 +189,7 @@ const CippGraphExplorerSimpleFilter = ({ variant="outlined" startIcon={viewMode === "table" ? : } onClick={() => onViewModeChange(viewMode === "table" ? "json" : "table")} - sx={{ minWidth: "120px" }} + sx={{ minWidth: { md: "120px" } }} > {viewMode === "table" ? "View JSON" : "View Table"} diff --git a/src/components/CippTable/CippMobileCardList.jsx b/src/components/CippTable/CippMobileCardList.jsx new file mode 100644 index 000000000000..f817177544bd --- /dev/null +++ b/src/components/CippTable/CippMobileCardList.jsx @@ -0,0 +1,442 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Box, + Button, + Card, + Checkbox, + IconButton, + LinearProgress, + ListItemButton, + ListItemIcon, + ListItemText, + Skeleton, + Stack, + SvgIcon, + Typography, +} from "@mui/material"; +import { flexRender } from "material-react-table"; +import { Info, MoreVert, MoreHoriz, SearchOff } from "@mui/icons-material"; +import { getCippTranslation } from "../../utils/get-cipp-translation"; +import { renderUrlValue } from "../../utils/render-url-value"; +import { getMobileCardSlots } from "./util-mobile-card-slots"; +import { CippBottomSheet } from "../CippComponents/CippBottomSheet"; +import { CippPageActionsFab } from "../CippComponents/CippPageActionsFab"; +import { useActionCornerClaim } from "../../layouts/tab-navigation-context"; +import { useSheetHandoff } from "../../hooks/use-sheet-handoff"; + +// Mobile card pageSize ceiling: a desktop tablePageSize of 250/500 must not become +// 250 unvirtualized cards. "Load more" grows pageSize from here in steps of LOAD_STEP. +const MOBILE_PAGE_SIZE_CAP = 50; +const LOAD_STEP = 50; + +// Chip values that say nothing without their field name beside them. +const MUTE_ALONE = new Set(["high", "medium", "low", "critical", "informational"]); + +// Render one column's value for a row. Generated columns (util-columnsFromAPI) only use +// { row } in their Cell, but page-supplied columns may expect fuller MRT context, so we +// hand over the real cell context when the cell exists. +const renderCellValue = (row, column, table) => { + const columnDef = column?.columnDef ?? column; + try { + const cell = row.getAllCells().find((c) => c.column.id === column.id); + // A portal cell is a bare icon — legible under its column header, not on a card row + // that only carries a label. Spell the link out from the raw value instead. + const linked = renderUrlValue(row.original?.[column.id], column.id); + if (linked) return linked; + if (typeof columnDef?.Cell === "function") { + return flexRender(columnDef.Cell, { + row, + cell, + column: cell?.column ?? column, + table, + renderedCellValue: cell ? cell.getValue() : row.getValue(column.id), + }); + } + return cell ? cell.getValue() : row.getValue(column.id); + } catch { + return null; + } +}; + +// String form for the card title/subtitle: the accessorFn output (getCippFormatting text +// mode for generated columns) — never a React node inside noWrap Typography. +const textValue = (row, column) => { + if (!column) return null; + try { + const value = row.getValue(column.id); + return typeof value === "string" || typeof value === "number" ? String(value) : null; + } catch { + return null; + } +}; + +const SkeletonCard = () => ( + + + + + + + + + + + + + +); + +export const CippMobileCardList = (props) => { + const { + table, + actions, + hasOffCanvas = false, + onRowAction, + onMoreInfo, + isActionDisabled, + getActionRow = (row) => row, + selectMode = false, + cardButton, + mobileCard, + fixedChrome = true, + onClearFilters, + isStreaming = false, + queueMessage, + } = props; + + const [actionSheetRow, setActionSheetRow] = useState(null); + // Row actions and More info both open a Modal — hand the sheet off rather than racing it + const rowSheet = useSheetHandoff(() => setActionSheetRow(null)); + + // Select mode's bulk bar owns the bottom of the screen, so the page FAB steps aside. Hold + // the corner through it anyway: a headered layout would otherwise drop its actions FAB in + // behind the bulk bar. Navigation is unaffected — the tab picker is in the title row. + useActionCornerClaim(fixedChrome && selectMode); + + // A desktop tablePageSize above the cap would render that many unvirtualized cards. + useEffect(() => { + if (table.getState().pagination.pageSize > MOBILE_PAGE_SIZE_CAP) { + table.setPageSize(MOBILE_PAGE_SIZE_CAP); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const rows = table.getRowModel().rows; + const totalFiltered = table.getFilteredRowModel().rows.length; + const showSkeletons = table.getState().showSkeletons; + const { globalFilter, columnFilters } = table.getState(); + const hasActiveFilter = Boolean(globalFilter) || (columnFilters?.length ?? 0) > 0; + + const visibleColumns = table.getVisibleLeafColumns(); + const slots = useMemo( + () => getMobileCardSlots(visibleColumns, mobileCard), + // visibleColumns is a fresh array each call — key on the ids it contains + // eslint-disable-next-line react-hooks/exhaustive-deps + [visibleColumns.map((c) => c.id).join(","), mobileCard] + ); + + const rowActionItems = (row) => + (actions ?? []).filter( + (action) => + typeof action.hideCondition !== "function" || !action.hideCondition(getActionRow(row.original)) + ); + + // Detail rows that would waste space: empty values, or values already shown as the + // card's title/subtitle (e.g. mail duplicating the UPN on most user rows). + const visibleDetailColumns = (row) => { + const shown = [textValue(row, slots.primary), slots.secondary && textValue(row, slots.secondary)] + .filter(Boolean) + .map((v) => v.toLowerCase()); + return slots.details.filter((col) => { + let raw; + try { + raw = row.getValue(col.id); + } catch { + return true; + } + if (raw === null || raw === undefined || raw === "") return false; + if (Array.isArray(raw) && raw.length === 0) return false; + if (typeof raw === "string" && shown.includes(raw.toLowerCase())) return false; + return true; + }); + }; + + const handleCardTap = (event, row) => { + if ( + event.target?.closest?.( + 'button, a, input, textarea, select, [role="button"], [role="menuitem"], [data-no-row-click="true"]' + ) + ) { + return; + } + if (selectMode) { + row.toggleSelected(); + return; + } + if (hasOffCanvas) { + onMoreInfo?.(row.original); + } + }; + + const handleLoadMore = () => { + table.setPageSize(table.getState().pagination.pageSize + LOAD_STEP); + }; + + const loadedCount = Math.min(rows.length, totalFiltered); + + return ( + + {isStreaming && !showSkeletons && } + {/* pb clears the fixed FAB / bulk bar — chrome an embedded (noCard/dialog) list does + not have, so it pays a normal gap instead of 80px of blank card. */} + + {showSkeletons ? ( + Array.from({ length: 5 }, (_, i) => ) + ) : totalFiltered === 0 ? ( + + + {queueMessage ? : } + + + {queueMessage ?? "No results"} + + {hasActiveFilter && ( + <> + + Nothing matches the current search and filters. + + + + )} + + ) : ( + <> + {rows.map((row) => { + const selected = row.getIsSelected(); + const detailColumns = visibleDetailColumns(row); + return ( + handleCardTap(event, row)} + sx={{ + p: 2, + display: "flex", + gap: 1.25, + position: "relative", + cursor: selectMode || hasOffCanvas ? "pointer" : "default", + ...(selected && { + borderColor: "primary.main", + bgcolor: (theme) => + theme.palette.mode === "dark" + ? "rgba(247,127,0,.08)" + : "primary.alpha8", + }), + }} + > + {selectMode && ( + row.toggleSelected()} + sx={{ alignSelf: "flex-start", p: 1, m: -0.5 }} + inputProps={{ "aria-label": `Select ${textValue(row, slots.primary) ?? row.id}` }} + /> + )} + + + {textValue(row, slots.primary) ?? "—"} + + {slots.secondary && ( + + {textValue(row, slots.secondary)} + + )} + {slots.chips.length > 0 && ( + + {slots.chips.map((col) => { + // Booleans format as a bare ✓/✕ icon — meaningful under a column + // header, meaningless floating on a card. Give those chips their + // field name in a labeled pill ("Primary ✓", "Account Enabled ✕"). + // Severity words are just as mute alone: a "High" chip beside a + // "Passed" chip doesn't say what is high, so those keep their field + // name too — as a caption, since the chip is its own container. + const text = textValue(row, col); + const isBareBoolean = text === "Yes" || text === "No"; + const isMuteAlone = MUTE_ALONE.has(String(text ?? "").toLowerCase()); + return ( + + {(isBareBoolean || isMuteAlone) && ( + + {getCippTranslation(col.id)} + + )} + {renderCellValue(row, col, table)} + + ); + })} + + )} + {detailColumns.length > 0 && ( + // Grid so every label shares the width of the longest one — no fixed + // label column truncating "Business Phones" while values sit half-empty. + + {detailColumns.map((col) => ( + + + {getCippTranslation(col.id)} + + *": { verticalAlign: "middle" }, + }} + > + {renderCellValue(row, col, table)} + + + ))} + + )} + {slots.restCount > 0 && hasOffCanvas && ( + { + event.stopPropagation(); + onMoreInfo?.(row.original); + }} + role="button" + > + +{slots.restCount} more field{slots.restCount === 1 ? "" : "s"} + + )} + + {(actions?.length > 0 || hasOffCanvas) && !selectMode && ( + { + event.stopPropagation(); + setActionSheetRow(row); + }} + sx={{ position: "absolute", top: 4, right: 4, minWidth: 44, minHeight: 44 }} + > + + + )} + + ); + })} + + + Showing {loadedCount} of {totalFiltered} + {isStreaming ? " (loading…)" : ""} + + {loadedCount < totalFiltered && ( + + )} + + + )} + + + {/* Page-level add actions: the cardButton children, stacked in a sheet behind one FAB */} + {cardButton && fixedChrome && !selectMode && ( + {cardButton} + )} + + {/* Row actions sheet — same actions array, same dispatch as the desktop row menu */} + + {actionSheetRow && + rowActionItems(actionSheetRow).map((action, index) => { + const disabled = isActionDisabled?.(actionSheetRow.original, action) ?? false; + return ( + + rowSheet.run(() => onRowAction?.(action, actionSheetRow.original)) + } + sx={{ minHeight: 48, color: action.color }} + > + + {action.icon} + + + + ); + })} + {actionSheetRow && hasOffCanvas && ( + rowSheet.run(() => onMoreInfo?.(actionSheetRow.original))} + sx={{ minHeight: 48 }} + > + + + + + + + + )} + + + ); +}; diff --git a/src/components/CippTable/CippMobileTableControls.jsx b/src/components/CippTable/CippMobileTableControls.jsx new file mode 100644 index 000000000000..57e19e319482 --- /dev/null +++ b/src/components/CippTable/CippMobileTableControls.jsx @@ -0,0 +1,312 @@ +import { useState } from "react"; +import { + Badge, + Box, + Button, + Divider, + ListItemButton, + ListItemIcon, + ListItemText, + SvgIcon, + Typography, +} from "@mui/material"; +import { + ModernSearchContainer, + ModernSearchInput, + ModernButton, + ModernIconButton, +} from "./toolbar-primitives"; +import { + ArrowDownward, + ArrowUpward, + MoreVert, + RestartAlt, + Search, + SwapVert, + TableChart, +} from "@mui/icons-material"; +import { getCippTranslation } from "../../utils/get-cipp-translation"; +import { CippBottomSheet } from "../CippComponents/CippBottomSheet"; +import { CippTableFilterSheet } from "./CippTableFilterSheet"; +import { useSheetHandoff } from "../../hooks/use-sheet-handoff"; + +// Presentational mobile controls for the card list. All filter/sort/visibility state and +// handlers are owned by CIPPTableToptoolbar (the same instance the desktop toolbar uses), +// so persistence, presets, and graph filters flow through exactly one code path. +export const CippMobileTableControls = (props) => { + const { + table, + searchValue, + onSearchChange, + onRefresh, + isRefreshing = false, + selectionEnabled = false, + selectMode = false, + onSelectModeChange, + selectModeLocked = false, + onViewToggle, + customBulkActions = [], + onBulkAction, + graphPresetItems = [], + tablePresetItems = [], + activeFilters = { graph: null, table: null }, + activeSlotCount = 0, + presetKey, + onPresetClick, + onResetFilters, + onEditGraphFilters, + columnItems = [], + onToggleColumn, + exportEnabled = false, + onExportCsv, + onExportPdf, + onViewApiResponse, + fixedChrome = true, + embedded = false, + queueTracker, + dataSourceControls, + } = props; + + const [sortOpen, setSortOpen] = useState(false); + const [filterOpen, setFilterOpen] = useState(false); + const [bulkOpen, setBulkOpen] = useState(false); + // Graph filters, the API-response drawer and bulk dialogs are all Modals; let the sheet + // finish closing before they mount (see useSheetHandoff). + const filterSheet = useSheetHandoff(() => setFilterOpen(false)); + const bulkSheet = useSheetHandoff(() => setBulkOpen(false)); + + const sorting = table.getState().sorting ?? []; + const sortableColumns = table + .getAllColumns() + .filter((column) => !column.id.startsWith("mrt-") && column.getCanSort()); + + // Tap cycles: none -> asc -> desc -> none. Single-column sort — replaces, not appends. + const cycleSort = (columnId) => { + const current = sorting.find((s) => s.id === columnId); + if (!current) { + table.setSorting([{ id: columnId, desc: false }]); + } else if (!current.desc) { + table.setSorting([{ id: columnId, desc: true }]); + } else { + table.setSorting([]); + } + }; + + const selectedCount = table.getSelectedRowModel().rows.length; + const totalCount = table.getFilteredRowModel().rows.length; + const enabledBulkActions = customBulkActions.filter((action) => !action.disabled); + + return ( + <> + + + + + + {selectionEnabled && !selectModeLocked && ( + onSelectModeChange?.(!selectMode)} + sx={{ height: 44, flexShrink: 0 }} + > + {selectMode ? "Cancel" : "Select"} + + )} + setSortOpen(true)} + sx={sorting.length ? { borderColor: "primary.main", color: "primary.main" } : undefined} + > + + + {/* kebab, the sheet is a grab-bag (presets, fields, export, refresh), not just filters */} + setFilterOpen(true)} + sx={ + activeSlotCount > 0 + ? { borderColor: "primary.main", color: "primary.main" } + : undefined + } + > + + + + + {onViewToggle && ( + + {/* destination icon: tapping here opens the table */} + + + )} + + {queueTracker && {queueTracker}} + + {/* Sort sheet — net-new on mobile: cards have no column headers to click */} + setSortOpen(false)} + title="Sort by" + footer={ + + } + > + {sortableColumns.map((column) => { + const current = sorting.find((s) => s.id === column.id); + return ( + cycleSort(column.id)} + sx={{ minHeight: 48, color: current ? "primary.main" : "inherit" }} + > + + {current && ( + + {current.desc ? : } + + )} + + ); + })} + {sorting.length > 0 && ( + <> + + table.setSorting([])} sx={{ minHeight: 48 }}> + + + + + + + )} + + + {/* Filter sheet — presets first, then table utilities, then card fields */} + + + {/* Bulk action bar — bottom, in thumb reach, instead of the desktop top-toolbar strip */} + {selectMode && selectionEnabled && ( + theme.zIndex.speedDial, + display: "flex", + alignItems: "center", + gap: 1, + px: 1.5, + pt: 1.25, + pb: "calc(env(safe-area-inset-bottom) + 12px)", + bgcolor: "background.paper", + borderTop: 1, + borderColor: "divider", + }} + > + + {selectedCount} selected + + + {customBulkActions.length > 0 && ( + + )} + {!selectModeLocked && ( + + )} + + )} + + {/* Bulk actions sheet — the same customBulkActions + dispatch as the desktop menu */} + + {customBulkActions.map((action, index) => ( + bulkSheet.run(() => onBulkAction(action))} + sx={{ minHeight: 48 }} + > + + {action.icon} + + + + ))} + + + ); +}; diff --git a/src/components/CippTable/CippQueueTracker.js b/src/components/CippTable/CippQueueTracker.js index ac35062aa3c3..66521172833b 100644 --- a/src/components/CippTable/CippQueueTracker.js +++ b/src/components/CippTable/CippQueueTracker.js @@ -258,7 +258,7 @@ export const CippQueueTracker = ({ queueId, queryKey, title, onQueueComplete }) />
    - + Total Tasks: {(persistentQueueData || queueData).TotalTasks || 0} @@ -364,13 +364,23 @@ export const CippQueueTracker = ({ queueId, queryKey, title, onQueueComplete }) direction="row" justifyContent="space-between" alignItems="center" + spacing={1} > - + {/* Task names are tenant domains — one unbreakable token — so + without minWidth: 0 the row's min-content width exceeds a + phone-width card and shoves the status pill off its edge. */} + {task.Name} ({ + flexShrink: 0, + whiteSpace: "nowrap", px: 1.5, py: 0.5, borderRadius: 2, diff --git a/src/components/CippTable/CippTableCardButton.jsx b/src/components/CippTable/CippTableCardButton.jsx new file mode 100644 index 000000000000..d3960a6e5fc9 --- /dev/null +++ b/src/components/CippTable/CippTableCardButton.jsx @@ -0,0 +1,73 @@ +import React from 'react' +import { Button } from '@mui/material' +import { Stack } from '@mui/system' +import { CippApiDialog } from '../CippComponents/CippApiDialog' +import { useDialog } from '../../hooks/use-dialog' +import { resolveRowTemplates } from '../../utils/resolve-row-templates' + +const isActionConfig = (value) => + Boolean(value) && + typeof value === 'object' && + !React.isValidElement(value) && + !Array.isArray(value) && + (typeof value.url === 'string' || typeof value.link === 'string') + +const CippTableActionButton = ({ action, row }) => { + const createDialog = useDialog() + + if (typeof action.condition === 'function' && !action.condition(row)) { + return null + } + + return ( + <> + + + + ) +} + +export const CippTableCardButton = ({ cardButton, row }) => { + if (!cardButton) { + return null + } + if (typeof cardButton === 'function') { + return cardButton(row) + } + if (Array.isArray(cardButton)) { + return ( + + {cardButton.map((item, index) => ( + + ))} + + ) + } + if (isActionConfig(cardButton)) { + return + } + return cardButton +} diff --git a/src/components/CippTable/CippTableFilterSheet.jsx b/src/components/CippTable/CippTableFilterSheet.jsx new file mode 100644 index 000000000000..373fa59f586a --- /dev/null +++ b/src/components/CippTable/CippTableFilterSheet.jsx @@ -0,0 +1,215 @@ +import { + Box, + Button, + Checkbox, + Chip, + Divider, + ListItemButton, + ListItemIcon, + ListItemText, + ListSubheader, + Stack, +} from "@mui/material"; +import { + Check, + DataObject, + FileDownload, + FilterList, + PictureAsPdf, + RestartAlt, + Sync, +} from "@mui/icons-material"; +import { getCippTranslation } from "../../utils/get-cipp-translation"; +import { CippBottomSheet } from "../CippComponents/CippBottomSheet"; + +// Shared filter bottom sheet — presets, then the table utilities (refresh, export, reset), +// then field visibility. Used by the mobile card list and the mobile/compact table toolbar, +// one code path for both. +export const CippTableFilterSheet = (props) => { + const { + open, + onClose, + onExited, + run, + tablePresetItems = [], + graphPresetItems = [], + activeFilters = { graph: null, table: null }, + presetKey, + onPresetClick, + columnItems = [], + onToggleColumn, + onResetFilters, + onEditGraphFilters, + exportEnabled = false, + onExportCsv, + onExportPdf, + onViewApiResponse, + onRefresh, + isRefreshing = false, + // section renders only when onPageSizeChange is provided (the table-view sheet) + pageSize, + onPageSizeChange, + pageSizeOptions = [], + dataSourceControls, + } = props; + + const renderPresetChips = (items, layer) => ( + + {items.map((filter) => { + const key = presetKey(filter); + const active = activeFilters[layer]?.id === key; + return ( + : undefined} + onClick={() => onPresetClick(filter)} + sx={{ height: 36, borderRadius: 999 }} + /> + ); + })} + + ); + + return ( + + Done + + } + > + {dataSourceControls && ( + <> + + Data source + + {dataSourceControls} + + )} + {tablePresetItems.length > 0 && ( + <> + + Presets + + {renderPresetChips(tablePresetItems, "table")} + + )} + {graphPresetItems.length > 0 && ( + <> + + Graph filters + + {renderPresetChips(graphPresetItems, "graph")} + + )} + {/* Utilities above the field list: "Fields shown" is a checkbox per column — a dozen + rows on a wide table — so anything below it starts a long scroll down, and refresh, + export and reset are what this sheet gets opened for far more often. */} + + { + onResetFilters(); + onClose(); + }} + sx={{ minHeight: 48 }} + > + + + + + + {onEditGraphFilters && ( + run(onEditGraphFilters)} sx={{ minHeight: 48 }}> + + + + + + )} + {exportEnabled && ( + <> + + + + + + + + + + + + + + )} + run(onViewApiResponse)} sx={{ minHeight: 48 }}> + + + + + + { + onRefresh(); + onClose(); + }} + sx={{ minHeight: 48 }} + > + + + + + + {columnItems.length > 0 && ( + <> + + + Fields shown + + {columnItems.map((column) => ( + onToggleColumn(column.id, column.visible)} + sx={{ minHeight: 44, py: 0 }} + > + + + + ))} + + )} + {onPageSizeChange && pageSizeOptions.length > 0 && ( + <> + + Rows per page + + + {pageSizeOptions.map((option) => { + const active = option === pageSize; + return ( + : undefined} + onClick={() => onPageSizeChange(option)} + sx={{ height: 36, borderRadius: 999 }} + /> + ); + })} + + + )} + + ); +}; diff --git a/src/components/CippTable/toolbar-primitives.js b/src/components/CippTable/toolbar-primitives.js new file mode 100644 index 000000000000..cfea4a82d585 --- /dev/null +++ b/src/components/CippTable/toolbar-primitives.js @@ -0,0 +1,103 @@ +import { styled, alpha } from '@mui/material/styles' +import { Button, IconButton, InputBase, Paper } from '@mui/material' + +// shared toolbar styling for the desktop table toolbar and the mobile card controls bar + +export const ModernSearchContainer = styled(Paper)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + width: '100%', + maxWidth: '300px', + minWidth: '200px', + height: '40px', + backgroundColor: theme.palette.mode === 'dark' ? '#2A2D3A' : '#F8F9FA', + border: `1px solid ${theme.palette.mode === 'dark' ? '#404040' : '#E0E0E0'}`, + borderRadius: '8px', + padding: '0 12px', + '&:hover': { + borderColor: theme.palette.primary.main, + }, + '&:focus-within': { + borderColor: theme.palette.primary.main, + boxShadow: `0 0 0 2px ${alpha(theme.palette.primary.main, 0.2)}`, + }, + [theme.breakpoints.down('md')]: { + minWidth: '0', + maxWidth: 'none', + flex: 1, + }, +})) + +export const ModernSearchInput = styled(InputBase)(({ theme }) => ({ + marginLeft: theme.spacing(1), + flex: 1, + fontSize: '14px', + '& .MuiInputBase-input': { + padding: '8px 0', + '&::placeholder': { + color: theme.palette.text.secondary, + opacity: 0.7, + }, + }, +})) + +export const ModernButton = styled(Button)(({ theme }) => ({ + height: '40px', + borderRadius: '8px', + textTransform: 'none', + fontWeight: 500, + fontSize: '14px', + padding: '8px 16px', + backgroundColor: theme.palette.mode === 'dark' ? '#2A2D3A' : '#F8F9FA', + border: `1px solid ${theme.palette.mode === 'dark' ? '#404040' : '#E0E0E0'}`, + color: theme.palette.text.primary, + minWidth: 'auto', + whiteSpace: 'nowrap', + '&:hover': { + backgroundColor: theme.palette.mode === 'dark' ? '#363A4A' : '#F0F0F0', + borderColor: theme.palette.primary.main, + }, + '& .MuiButton-startIcon': { + marginRight: '8px', + }, + '& .MuiButton-endIcon': { + marginLeft: '8px', + }, + [theme.breakpoints.down('md')]: { + padding: '8px 12px', + fontSize: '13px', + '& .MuiButton-startIcon': { + marginRight: '6px', + }, + '& .MuiButton-endIcon': { + marginLeft: '6px', + }, + }, + [theme.breakpoints.down('sm')]: { + padding: '8px 10px', + fontSize: '12px', + '& .MuiButton-startIcon': { + marginRight: '4px', + }, + '& .MuiButton-endIcon': { + marginLeft: '4px', + }, + }, +})) + +// tonal icon button matching ModernButton, 44px for phone touch targets +export const ModernIconButton = styled(IconButton)(({ theme }) => ({ + width: '44px', + height: '44px', + borderRadius: '8px', + backgroundColor: theme.palette.mode === 'dark' ? '#2A2D3A' : '#F8F9FA', + border: `1px solid ${theme.palette.mode === 'dark' ? '#404040' : '#E0E0E0'}`, + color: theme.palette.text.primary, + flexShrink: 0, + '&:hover': { + backgroundColor: theme.palette.mode === 'dark' ? '#363A4A' : '#F0F0F0', + borderColor: theme.palette.primary.main, + }, +})) + +export const RefreshButton = styled(IconButton)(({ theme }) => ({})) diff --git a/src/components/CippTable/util-columnsFromAPI.js b/src/components/CippTable/util-columnsFromAPI.js index ad170fc3ddff..e530b18f3ab6 100644 --- a/src/components/CippTable/util-columnsFromAPI.js +++ b/src/components/CippTable/util-columnsFromAPI.js @@ -32,12 +32,13 @@ const TIME_AGO_NAMES = new Set([ 'Date', 'WhenCreated', 'WhenChanged', 'CreationTime', 'renewalDate', 'commitmentTerm.renewalConfiguration.renewalDate', 'purchaseDate', 'NextOccurrence', 'LastOccurrence', 'NotBefore', 'NotAfter', 'latestDataCollection', - 'requestDate', 'reviewedDate', 'GeneratedAt', + 'requestDate', 'reviewedDate', 'GeneratedAt', 'RecordedAt', ]) const MATCH_DATE_TIME = /([dD]ate[tT]ime|[Ee]xpiration|[Tt]imestamp|[sS]tart[Dd]ate)/ const ABSOLUTE_DATE_NAMES = new Set([ 'WindowStart', 'WindowEnd', 'CreatedUtc', 'DownloadedUtc', 'ProcessedUtc', 'NextAttemptUtc', 'LastErrorUtc', 'LastPolledUtc', + 'QueuedUtc', 'StartedUtc', 'CompletedUtc', ]) const isDateTimeColumn = (key) => TIME_AGO_NAMES.has(key) || ABSOLUTE_DATE_NAMES.has(key) || MATCH_DATE_TIME.test(key) @@ -294,7 +295,7 @@ export const utilColumnsFromAPI = (dataArray) => { sampleValue, values: valuesForColumn, getValue: (row) => resolveValue(row), - dataArray: filterSample, + dataArray, }), Cell: ({ row }) => { const value = resolveValue(row.original) diff --git a/src/components/CippTable/util-mobile-card-slots.js b/src/components/CippTable/util-mobile-card-slots.js new file mode 100644 index 000000000000..fcfead619804 --- /dev/null +++ b/src/components/CippTable/util-mobile-card-slots.js @@ -0,0 +1,143 @@ +// Pure slotting function for the mobile card list: decides which visible columns become +// the card title, subtitle, status chips, and detail rows. Runs unattended across every +// table page, so the rules are deliberate: +// +// primary — first NAME_FIELDS match, else first non-status textual column, else the +// first column. Never naively "first column": the users page's first +// simpleColumn is accountEnabled, which would title every card "Yes". +// secondary — first IDENTIFIER_FIELDS match that isn't the primary. +// chips — up to 3 status-like columns (boolean sortingFn, known status ids, or +// small select filters). +// details — up to 3 of whatever remains, in simpleColumns order. +// rest — everything else, surfaced as "+N more fields" -> detail drawer. +// +// Pages that know better pass mobileCard={{primary, secondary, chips, details}} to +// override any slot; ids not present in the visible columns are ignored. + +const NAME_FIELDS = [ + "displayName", + "DisplayName", + "Name", + "name", + "Title", + "title", + "deviceName", + "hostname", + "TenantName", + "Tenant", + "subject", + "RowKey", +]; + +const IDENTIFIER_FIELDS = [ + "userPrincipalName", + "UPN", + "mail", + "primarySmtpAddress", + "defaultDomainName", + "serialNumber", + "id", + "RowKey", +]; + +// Known enum-ish ids that read as status even when their filter variant doesn't say so. +// accountEnabled is here because get-cipp-filter-variant gives it an explicit select case +// with alphanumeric sorting and no options — none of the generic signals fire for it. +const STATUS_FIELDS = new Set( + [ + "severity", + "risk", + "result", + "status", + "state", + "compliancestate", + "risklevel", + "riskstate", + "usertype", + "outcome", + "healthstate", + "isenabled", + "enabled", + "accountenabled", + ].map((f) => f.toLowerCase()) +); + +const columnId = (col) => col?.id ?? col?.columnDef?.id ?? col?.accessorKey; +const columnDef = (col) => col?.columnDef ?? col; + +export const isStatusLike = (col) => { + const def = columnDef(col); + if (def?.sortingFn === "boolean") return true; + const id = String(columnId(col) ?? "").toLowerCase(); + if (STATUS_FIELDS.has(id)) return true; + if ( + def?.filterVariant === "select" && + Array.isArray(def?.filterSelectOptions) && + def.filterSelectOptions.length > 0 && + def.filterSelectOptions.length <= 6 + ) { + return true; + } + return false; +}; + +const firstMatch = (columns, priorityList, exclude = new Set()) => { + for (const fieldName of priorityList) { + const match = columns.find((col) => columnId(col) === fieldName && !exclude.has(col)); + if (match) return match; + } + return null; +}; + +/** + * @param {Array} visibleColumns columns from table.getVisibleLeafColumns() (or any array of + * objects carrying id + columnDef); mrt-* utility columns are filtered out here. + * @param {Object} [override] optional mobileCard prop: {primary, secondary, chips, details} as ids. + * @returns {{primary, secondary, chips: [], details: [], rest: [], restCount: number}} + * primary/secondary are columns (or null); chips/details/rest are column arrays. + */ +export const getMobileCardSlots = (visibleColumns, override = {}) => { + const columns = (visibleColumns ?? []).filter( + (col) => !String(columnId(col) ?? "").startsWith("mrt-") + ); + + if (columns.length === 0) { + return { primary: null, secondary: null, chips: [], details: [], rest: [], restCount: 0 }; + } + + const byId = (id) => columns.find((col) => columnId(col) === id); + const used = new Set(); + + const primary = + (override.primary && byId(override.primary)) || + firstMatch(columns, NAME_FIELDS) || + columns.find((col) => !isStatusLike(col)) || + columns[0]; + used.add(primary); + + const secondary = + (override.secondary && override.secondary !== columnId(primary) && byId(override.secondary)) || + firstMatch(columns, IDENTIFIER_FIELDS, used) || + null; + if (secondary) used.add(secondary); + + let chips; + if (Array.isArray(override.chips)) { + chips = override.chips.map(byId).filter((col) => col && !used.has(col)); + } else { + chips = columns.filter((col) => !used.has(col) && isStatusLike(col)).slice(0, 3); + } + chips.forEach((col) => used.add(col)); + + let details; + if (Array.isArray(override.details)) { + details = override.details.map(byId).filter((col) => col && !used.has(col)); + } else { + details = columns.filter((col) => !used.has(col)).slice(0, 3); + } + details.forEach((col) => used.add(col)); + + const rest = columns.filter((col) => !used.has(col)); + + return { primary, secondary, chips, details, rest, restCount: rest.length }; +}; diff --git a/src/components/CippTable/util-subTables.js b/src/components/CippTable/util-subTables.js new file mode 100644 index 000000000000..60b256bc4e33 --- /dev/null +++ b/src/components/CippTable/util-subTables.js @@ -0,0 +1,75 @@ +const hasOwn = (row, key) => + Boolean(key) && row != null && typeof row === 'object' && Object.prototype.hasOwnProperty.call(row, key) + +const hasPopulatedColumnValue = (row, columnId) => { + if (!hasOwn(row, columnId)) { + return false + } + const value = row[columnId] + if (value == null) { + return false + } + if (typeof value === 'string') { + return value.trim().length > 0 + } + if (Array.isArray(value)) { + return value.length > 0 + } + return true +} + +export const dataHasPopulatedColumn = (data, columnId) => + Boolean(columnId) && + Array.isArray(data) && + data.some((row) => hasPopulatedColumnValue(row, columnId)) + +export const subTableIsSelected = (sub, selectedIds) => { + if (!sub?.id) { + return false + } + if (!Array.isArray(selectedIds) || selectedIds.length === 0) { + return true + } + return selectedIds.includes(sub.id) +} + +export const subTableShowsCachedColumn = (sub, data) => + Boolean(sub?.cachedColumn) && dataHasPopulatedColumn(data, sub.cachedColumn) + +export const resolveSubTableSimpleColumns = (simpleColumns, subTables, data) => { + if (!Array.isArray(simpleColumns) || !Array.isArray(subTables) || subTables.length === 0) { + return simpleColumns + } + + return simpleColumns.map((id) => { + const sub = subTables.find((item) => item.id === id) + if (sub && subTableShowsCachedColumn(sub, data)) { + return sub.cachedColumn + } + return id + }) +} + +export const getSubTableDisplayColumnIds = (subTables, simpleColumns, data) => { + if (!Array.isArray(subTables) || subTables.length === 0) { + return [] + } + const ids = [] + for (const sub of subTables) { + if (!subTableIsSelected(sub, simpleColumns)) { + continue + } + const columnId = subTableShowsCachedColumn(sub, data) ? sub.cachedColumn : sub.id + if (columnId) { + ids.push(columnId) + } + } + return ids +} + +export const columnOrderHasStaleIds = (columnOrder, displayColumnIds) => { + const displayIdSet = new Set(displayColumnIds) + return (columnOrder ?? []).some( + (id) => id && !String(id).startsWith('mrt-') && !displayIdSet.has(id) + ) +} diff --git a/src/components/CippTable/util-tablemode.js b/src/components/CippTable/util-tablemode.js index 8e5120ebb1a5..92fbbcd2abe2 100644 --- a/src/components/CippTable/util-tablemode.js +++ b/src/components/CippTable/util-tablemode.js @@ -1,3 +1,7 @@ +// Card mode renders its own list, so a huge desktop tablePageSize preference must not +// become that many unvirtualized cards. CippMobileCardList grows pageSize from here. +const MOBILE_PAGE_SIZE_CAP = 50 + export const utilTableMode = ( columnVisibility, mode, @@ -6,7 +10,9 @@ export const utilTableMode = ( offCanvas, onChange, maxHeightOffset = '380px', - settings = {} + settings = {}, + viewMode = 'table', + narrowTable = false ) => { if (mode === true) { return { @@ -42,20 +48,34 @@ export const utilTableMode = ( }, } } else { + const configuredPageSize = settings?.tablePageSize?.value + ? parseInt(settings?.tablePageSize?.value, 10) + : 25 + const isCards = viewMode === 'cards' + return { enableRowSelection: actions || onChange ? true : false, enableRowActions: actions ? true : false, enableSelectAll: true, enableFacetedValues: true, enableColumnFilterModes: true, - enableStickyHeader: true, + enableStickyHeader: !isCards, selectAllMode: 'all', - enableColumnPinning: true, + enableColumnPinning: !isCards, muiPaginationProps: { rowsPerPageOptions: [25, 50, 100, 250, 500], + // a full footer wraps below MRT's 720px pivot, the extra row scrolls the page chrome + ...(narrowTable && { + showRowsPerPage: false, + showFirstButton: false, + showLastButton: false, + }), }, muiTableContainerProps: { - sx: { maxHeight: `calc(100vh - ${maxHeightOffset})` }, + // offset numbers are tuned against desktop chrome, narrow viewports page-scroll + sx: { + maxHeight: narrowTable ? 'none' : `calc(100vh - ${maxHeightOffset})`, + }, }, displayColumnDefOptions: { 'mrt-row-actions': { @@ -71,15 +91,17 @@ export const utilTableMode = ( showGlobalFilter: true, density: 'compact', pagination: { - pageSize: settings?.tablePageSize?.value - ? parseInt(settings?.tablePageSize?.value, 10) - : 25, + pageSize: isCards + ? Math.min(configuredPageSize, MOBILE_PAGE_SIZE_CAP) + : configuredPageSize, pageIndex: 0, }, - columnPinning: { - left: ['mrt-row-select'], - right: ['mrt-row-actions'], - }, + ...(!isCards && { + columnPinning: { + left: ['mrt-row-select'], + right: ['mrt-row-actions'], + }, + }), }, } } diff --git a/src/components/CippTestDetail/CippTestDetailOffCanvas.jsx b/src/components/CippTestDetail/CippTestDetailOffCanvas.jsx index 45e153d2ec5c..7bed81eb8768 100644 --- a/src/components/CippTestDetail/CippTestDetailOffCanvas.jsx +++ b/src/components/CippTestDetail/CippTestDetailOffCanvas.jsx @@ -144,16 +144,16 @@ export const CippTestDetailOffCanvas = ({ row }) => { + {/* short label + chip pairs: full-width rows left 80% of a phone empty — 2x2 there, + the same 4-across strip on desktop. two-up by design: mobile-layout-ok */} ({ xs: `1px solid ${theme.palette.divider}`, md: "none", }), - borderRight: (theme) => ({ - md: `1px solid ${theme.palette.divider}`, - }), + borderRight: (theme) => `1px solid ${theme.palette.divider}`, }} > @@ -167,8 +167,9 @@ export const CippTestDetailOffCanvas = ({ row }) => {
    + {/* two-up by design: mobile-layout-ok */} ({ xs: `1px solid ${theme.palette.divider}`, @@ -194,16 +195,11 @@ export const CippTestDetailOffCanvas = ({ row }) => { + {/* two-up by design: mobile-layout-ok */} ({ - xs: `1px solid ${theme.palette.divider}`, - md: "none", - }), - borderRight: (theme) => ({ - md: `1px solid ${theme.palette.divider}`, - }), + borderRight: (theme) => `1px solid ${theme.palette.divider}`, }} > @@ -221,12 +217,8 @@ export const CippTestDetailOffCanvas = ({ row }) => { - + {/* two-up by design: mobile-layout-ok */} + diff --git a/src/components/CippWizard/CippIntunePolicy.jsx b/src/components/CippWizard/CippIntunePolicy.jsx index 10d46a1b7e83..0325ced59f3a 100644 --- a/src/components/CippWizard/CippIntunePolicy.jsx +++ b/src/components/CippWizard/CippIntunePolicy.jsx @@ -185,7 +185,7 @@ export const CippIntunePolicy = (props) => { return null } return filteredPlaceholders.map((placeholder) => ( - + {selectedTenants.map((tenant, idx) => ( { const { values: initialValues, onPreviousStep, onNextStep } = props; const [values, setValues] = useState(initialValues); @@ -210,20 +211,14 @@ export const CippPSACredentialsStep = (props) => { )} - + - + ); diff --git a/src/components/CippWizard/CippPSASyncOptions.jsx b/src/components/CippWizard/CippPSASyncOptions.jsx index 146d5b26279e..a5ee2e01e693 100644 --- a/src/components/CippWizard/CippPSASyncOptions.jsx +++ b/src/components/CippWizard/CippPSASyncOptions.jsx @@ -12,6 +12,7 @@ import { TextField, Typography, } from "@mui/material"; +import { CippWizardActionsRow } from "./CippWizardActionsRow"; const options = [ { @@ -147,14 +148,14 @@ export const CippPSASyncOptions = (props) => { )} - + - + ); diff --git a/src/components/CippWizard/CippWizard.jsx b/src/components/CippWizard/CippWizard.jsx index 22f24de234bc..0c35ce677d14 100644 --- a/src/components/CippWizard/CippWizard.jsx +++ b/src/components/CippWizard/CippWizard.jsx @@ -34,9 +34,14 @@ export const CippWizard = (props) => { setActiveStep((prevState) => (prevState > 0 ? prevState - 1 : prevState)); }, []); + // Counts against the VISIBLE steps. `steps` is the unfiltered prop — the onboarding + // wizard passes 14 and shows 3-7 — so clamping against it let activeStep run past the + // end of stepsWithVisibility, and the render below then read `.component` of undefined. const handleNext = useCallback(() => { - setActiveStep((prevState) => (prevState < steps.length - 1 ? prevState + 1 : prevState)); - }, []); + setActiveStep((prevState) => + prevState < stepsWithVisibility.length - 1 ? prevState + 1 : prevState + ); + }, [stepsWithVisibility.length]); const content = useMemo(() => { const currentStep = stepsWithVisibility[activeStep]; @@ -57,7 +62,7 @@ export const CippWizard = (props) => { {...currentStep.componentProps} /> ); - }, [activeStep, handleNext, handleBack, stepsWithVisibility, formControl]); + }, [activeStep, handleNext, handleBack, stepsWithVisibility, formControl, postUrl]); // Get the maxWidth for the current step, fallback to global setting const currentStepMaxWidth = useMemo(() => { @@ -85,7 +90,9 @@ export const CippWizard = (props) => { ) : ( - + {/* 48px under a three-line stepper is right; under the compact mobile header it + is dead space. */} + { steps={stepsWithVisibility} />
    - {content} + {/* Below md this Container clamps nothing — maxWidth is md/lg — and its + gutters only duplicate the ones CardContent already pays. disableGutters + with px at md restores exactly Container's own value from md up. */} + + {content} +
    diff --git a/src/components/CippWizard/CippWizardActionsRow.jsx b/src/components/CippWizard/CippWizardActionsRow.jsx new file mode 100644 index 000000000000..0641f9dbb199 --- /dev/null +++ b/src/components/CippWizard/CippWizardActionsRow.jsx @@ -0,0 +1,47 @@ +import PropTypes from "prop-types"; +import { Stack } from "@mui/material"; + +/** + * The Back / Next / Submit row shared by the wizard step buttons and the three steps that + * roll their own. + * + * Presentational only — no behaviour, because the four call sites disagree about what the + * buttons DO (some gate Next on form validity, some own their submit) and only agree about + * how the row should sit. + * + * Below md the row stacks in `column-reverse`, which puts the primary action at the top and + * Close at the bottom. Two details are load-bearing: + * - `alignItems: stretch`, or a column would shrink every child to its content width. + * - the descendant selector rather than per-button `fullWidth`: the Submit button is + * wrapped in its own
    , so the form is the flex item and the button inside it is + * what needs the width. + */ +export const CippWizardActionsRow = (props) => { + const { sx, children } = props; + + return ( + + {children} + + ); +}; + +CippWizardActionsRow.propTypes = { + sx: PropTypes.object, + children: PropTypes.node, +}; diff --git a/src/components/CippWizard/CippWizardAutopilotImport.jsx b/src/components/CippWizard/CippWizardAutopilotImport.jsx index fe840fa8e80a..33be2733edf5 100644 --- a/src/components/CippWizard/CippWizardAutopilotImport.jsx +++ b/src/components/CippWizard/CippWizardAutopilotImport.jsx @@ -10,6 +10,8 @@ import { DialogActions, TextField, Alert, + Paper, + IconButton, } from '@mui/material' import { CippWizardStepButtons } from './CippWizardStepButtons' import { CippDataTable } from '../CippTable/CippDataTable' @@ -17,6 +19,7 @@ import { useWatch } from 'react-hook-form' import { Delete, FileDownload, Upload, Add } from '@mui/icons-material' import { useEffect, useState } from 'react' import React from 'react' +import { useIsMobileLayout } from '../../hooks/use-breakpoint' export const CippWizardAutopilotImport = (props) => { const { @@ -35,6 +38,7 @@ export const CippWizardAutopilotImport = (props) => { const [manualDialogOpen, setManualDialogOpen] = useState(false) const [manualInputs, setManualInputs] = useState([{}]) const inputRefs = React.useRef([]) + const isMobile = useIsMobileLayout() const [validationErrors, setValidationErrors] = useState([]) const handleRemoveItem = (row) => { @@ -404,28 +408,35 @@ export const CippWizardAutopilotImport = (props) => { ))} )} - {manualInputs.map((row, rowIndex) => ( - - {/* Row identifier */} + {manualInputs.map((row, rowIndex) => { + // Defined once and placed by either branch, so the two layouts cannot drift. + const fieldInputs = fields.map((field) => ( + + { + if (!inputRefs.current[rowIndex]) { + inputRefs.current[rowIndex] = {} + } + inputRefs.current[rowIndex][field.propertyName] = el + }} + label={field.friendlyName} + value={row[field.propertyName] || ''} + onChange={(e) => + handleManualInputChange(rowIndex, field.propertyName, e.target.value) + } + onKeyDown={(e) => + field.propertyName === 'productKey' && handleKeyPress(e, rowIndex) + } + fullWidth + size="small" + /> + + )) + + const rowNumber = ( { fontSize: '0.875rem', fontWeight: 600, flexShrink: 0, - ml: 1, + ml: isMobile ? 0 : 1, }} > {rowIndex + 1} - {fields.map((field) => ( - - { - if (!inputRefs.current[rowIndex]) { - inputRefs.current[rowIndex] = {} - } - inputRefs.current[rowIndex][field.propertyName] = el - }} - label={field.friendlyName} - value={row[field.propertyName] || ''} - onChange={(e) => - handleManualInputChange(rowIndex, field.propertyName, e.target.value) - } - onKeyDown={(e) => - field.propertyName === 'productKey' && handleKeyPress(e, rowIndex) - } - fullWidth - size="small" - /> - - ))} - - - ))} + {rowNumber} + {fieldInputs} + + + ) + })} { const lastAppliedTemplate = useRef(null); const groupOptions = [ { label: "Dynamic Group", value: "dynamic" }, - { label: "Dynamic Distribution Group", value: "dynamicDistribution" }, { label: "Security Group", value: "generic" }, { label: "Distribution Group", value: "distribution" }, { label: "Azure Role Group", value: "azureRole" }, diff --git a/src/components/CippWizard/CippWizardOffboarding.jsx b/src/components/CippWizard/CippWizardOffboarding.jsx index 2fc5947c22bf..eb58cd4fab8d 100644 --- a/src/components/CippWizard/CippWizardOffboarding.jsx +++ b/src/components/CippWizard/CippWizardOffboarding.jsx @@ -25,7 +25,8 @@ export const CippWizardOffboarding = (props) => { const currentTenant = formControl.watch('tenantFilter') const selectedUsers = useWatch({ control: formControl.control, name: 'user' }) const [showAlert, setShowAlert] = useState(false) - const userSettingsDefaults = useSettings().userSettingsDefaults + const settings = useSettings() + const userOffboardingDefaults = settings?.offboardingDefaults const disableForwarding = useWatch({ control: formControl.control, name: 'disableForwarding' }) const deleteUser = useWatch({ control: formControl.control, name: 'DeleteUser' }) const convertToShared = useWatch({ control: formControl.control, name: 'ConvertToShared' }) @@ -89,25 +90,24 @@ export const CippWizardOffboarding = (props) => { const tenantDefaults = currentTenant?.addedFields?.offboardingDefaults if (tenantDefaults) { - // Apply tenant defaults + // Apply tenant defaults; always clear OOO when the blob omits it so user defaults do not leak Object.entries(tenantDefaults).forEach(([key, value]) => { formControl.setValue(key, value) }) - // Set the source indicator + formControl.setValue('OOO', tenantDefaults.OOO ?? '') formControl.setValue('HIDDEN_defaultsSource', 'tenant') - } else if (userSettingsDefaults?.offboardingDefaults) { - // Apply user defaults if no tenant defaults - userSettingsDefaults.offboardingDefaults.forEach((setting) => { - formControl.setValue(setting.name, setting.value) + } else if (userOffboardingDefaults) { + Object.entries(userOffboardingDefaults).forEach(([key, value]) => { + formControl.setValue(key, value) }) - // Set the source indicator + formControl.setValue('OOO', userOffboardingDefaults.OOO ?? '') formControl.setValue('HIDDEN_defaultsSource', 'user') } // Mark that we've applied defaults for this tenant formControl.setValue('HIDDEN_appliedDefaultsForTenant', currentTenantId) } - }, [currentTenant?.value, userSettingsDefaults, formControl]) + }, [currentTenant?.value, userOffboardingDefaults, formControl]) useEffect(() => { if (disableForwarding) { @@ -123,7 +123,7 @@ export const CippWizardOffboarding = (props) => { return ( - + @@ -266,7 +266,7 @@ export const CippWizardOffboarding = (props) => { - + @@ -326,6 +326,61 @@ export const CippWizardOffboarding = (props) => { }, }} /> + `${option.displayName} (${option.userPrincipalName})`, + valueField: 'id', + url: '/api/ListGraphRequest', + dataKey: 'Results', + tenantFilter: currentTenant ? currentTenant.value : undefined, + queryKey: `Offboarding-Users-${currentTenant ? currentTenant.value : 'default'}`, + data: { + Endpoint: 'users', + manualPagination: true, + $select: 'id,userPrincipalName,displayName', + $count: true, + $orderby: 'displayName', + $top: 999, + }, + }} + /> + `${option.displayName} (${option.userPrincipalName})`, + valueField: 'id', + url: '/api/ListGraphRequest', + dataKey: 'Results', + tenantFilter: currentTenant ? currentTenant.value : undefined, + queryKey: `Offboarding-Users-${currentTenant ? currentTenant.value : 'default'}`, + data: { + Endpoint: 'users', + manualPagination: true, + $select: 'id,userPrincipalName,displayName', + $count: true, + $orderby: 'displayName', + $top: 999, + }, + }} + /> + + OneDrive Access + {deleteUser && ( When a user is deleted, their OneDrive is retained for 30 days by default unless @@ -335,7 +390,7 @@ export const CippWizardOffboarding = (props) => { { disabled={!!deleteUser} /> + + Out of Office + @@ -420,6 +478,10 @@ export const CippWizardOffboarding = (props) => { fullWidth formControl={formControl} /> + + CIPP %variable% tokens (for example %tenantname%) stay literal here and are + resolved when the offboarding job runs. %username% is not the offboarded user. + {convertToShared && oversizedMailboxes.length > 0 && ( diff --git a/src/components/CippWizard/CippWizardPage.jsx b/src/components/CippWizard/CippWizardPage.jsx index 0fa517fb6cc6..6a714493f53b 100644 --- a/src/components/CippWizard/CippWizardPage.jsx +++ b/src/components/CippWizard/CippWizardPage.jsx @@ -8,9 +8,7 @@ import { DialogTitle, Divider, IconButton, - Stack, SvgIcon, - useMediaQuery, } from "@mui/material"; import { Close } from "@mui/icons-material"; import { CippWizard } from "./CippWizard"; @@ -18,6 +16,7 @@ import { useRouter } from "next/router"; import { ArrowLeftIcon } from "@mui/x-date-pickers"; import { CippHead } from "../CippComponents/CippHead"; import { CippWizardDialogContext } from "./CippWizardDialogContext"; +import { useIsMobileLayout } from "../../hooks/use-breakpoint"; import { useState, useCallback } from "react"; const CippWizardPage = (props) => { @@ -39,7 +38,7 @@ const CippWizardPage = (props) => { ...other } = props; - const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md")); + const isMobile = useIsMobileLayout(); const [actionsEl, setActionsEl] = useState(null); const actionsRef = useCallback((el) => setActionsEl(el), []); @@ -59,12 +58,12 @@ const CippWizardPage = (props) => { onClose={onClose} fullWidth maxWidth="xl" - fullScreen={mdDown} + fullScreen={isMobile} PaperProps={{ sx: { display: "flex", flexDirection: "column", - ...(!mdDown && { height: "90vh" }), + ...(!isMobile && { height: "90vh" }), }, }} > @@ -76,7 +75,7 @@ const CippWizardPage = (props) => { - + @@ -84,7 +83,7 @@ const CippWizardPage = (props) => { - + ); } @@ -96,21 +95,18 @@ const CippWizardPage = (props) => { sx={{ backgroundColor: "background.default", flexGrow: 1, - pb: 4, + pb: { xs: 2, md: 4 }, }} > - - - - - {wizardNode} - - - - + {/* Three nested Stacks used to sit here, each wrapping exactly one child. Stack + spacing only emits a margin on :not(:first-of-type), so all three were inert + at every width. */} + + {wizardNode} + diff --git a/src/components/CippWizard/CippWizardProgressHeader.jsx b/src/components/CippWizard/CippWizardProgressHeader.jsx new file mode 100644 index 000000000000..bdb14abaf41a --- /dev/null +++ b/src/components/CippWizard/CippWizardProgressHeader.jsx @@ -0,0 +1,45 @@ +import PropTypes from "prop-types"; +import { LinearProgress, Stack, Typography } from "@mui/material"; + +/** + * The wizard's step indicator below md. + * + * A horizontal MUI Stepper gives every step a 36px icon beside two lines of text; with the + * 3-7 steps these wizards have, and ~326px of usable width on a phone, the labels collapse + * into each other. This says the same thing in the space available: where you are, what + * this step is, and how much is left. + * + * Takes the same two props as WizardSteps so the swap needs no new plumbing. + */ +export const CippWizardProgressHeader = (props) => { + const { activeStep = 0, steps = [] } = props; + + const total = steps.length; + // Clamped because handleNext currently counts against the unfiltered step list, so + // activeStep can point past the end of a wizard whose steps are conditionally hidden. + const index = total > 0 ? Math.min(Math.max(activeStep, 0), total - 1) : 0; + const current = steps[index]; + const value = total > 0 ? ((index + 1) / total) * 100 : 0; + + return ( + + + {total > 0 ? `Step ${index + 1} of ${total}` : "No steps"} + + {current?.description ?? current?.title ?? ""} + {/* Carries the same error/loading states the step icons show on desktop, so the + GDAP-style "this step failed" signal survives the swap. */} + + + ); +}; + +CippWizardProgressHeader.propTypes = { + activeStep: PropTypes.number, + steps: PropTypes.array, +}; diff --git a/src/components/CippWizard/CippWizardStepButtons.jsx b/src/components/CippWizard/CippWizardStepButtons.jsx index 7a070d124e08..55fb723d07b4 100644 --- a/src/components/CippWizard/CippWizardStepButtons.jsx +++ b/src/components/CippWizard/CippWizardStepButtons.jsx @@ -1,9 +1,10 @@ -import { Button, Stack } from "@mui/material"; +import { Button } from "@mui/material"; import { useFormState } from "react-hook-form"; import { createPortal } from "react-dom"; import { ApiPostCall } from "../../api/ApiCall"; import { CippApiResults } from "../CippComponents/CippApiResults"; import { useCippWizardDialog } from "./CippWizardDialogContext"; +import { CippWizardActionsRow } from "./CippWizardActionsRow"; export const CippWizardStepButtons = (props) => { const { @@ -47,20 +48,14 @@ export const CippWizardStepButtons = (props) => { }; const buttonStack = ( - + {dialogContext?.onClose && ( @@ -98,7 +93,7 @@ export const CippWizardStepButtons = (props) => { {dialogContext.completionButton.label} )} - + ); return ( diff --git a/src/components/CippWizard/CippWizardVacationActions.jsx b/src/components/CippWizard/CippWizardVacationActions.jsx index 7a8db5b558c7..cdf8130ea2bb 100644 --- a/src/components/CippWizard/CippWizardVacationActions.jsx +++ b/src/components/CippWizard/CippWizardVacationActions.jsx @@ -26,10 +26,15 @@ export const CippWizardVacationActions = (props) => { const tenantDomain = currentTenant?.value || currentTenant const enableCA = useWatch({ control: formControl.control, name: 'enableCAExclusion' }) + const enableLocationAlertExclusion = useWatch({ + control: formControl.control, + name: 'excludeLocationAuditAlerts', + }) const enableMailbox = useWatch({ control: formControl.control, name: 'enableMailboxPermissions' }) const enableForwarding = useWatch({ control: formControl.control, name: 'enableForwarding' }) const enableOOO = useWatch({ control: formControl.control, name: 'enableOOO' }) - const atLeastOneEnabled = enableCA || enableMailbox || enableForwarding || enableOOO + const atLeastOneEnabled = + enableCA || enableLocationAlertExclusion || enableMailbox || enableForwarding || enableOOO const users = useWatch({ control: formControl.control, name: 'Users' }) const firstUser = Array.isArray(users) && users.length > 0 ? users[0] : null @@ -194,14 +199,6 @@ export const CippWizardVacationActions = (props) => { disabled={!tenantDomain} /> - - - { + {/* Location Alert Exclusion Section */} + + + + + + + + + + The users are added to the audit log location alert exclusion list at the start + date and removed again at the end date, so alerts that fire on sign-ins from an + unusual location stay quiet while they travel. This works on its own and does not + require a Conditional Access policy. + + + + + + {/* Mailbox Permissions Section */} { const { formControl, onPreviousStep, currentStep, lastStep } = props @@ -22,6 +23,7 @@ export const CippWizardVacationConfirmation = (props) => { const values = useWatch({ control: formControl.control }) const caExclusion = ApiPostCall({ relatedQueryKeys: ['VacationMode'] }) + const auditExclusion = ApiPostCall({ relatedQueryKeys: ['VacationMode'] }) const mailboxVacation = ApiPostCall({ relatedQueryKeys: ['VacationMode'] }) const forwardingVacation = ApiPostCall({ relatedQueryKeys: ['VacationMode'] }) const oooVacation = ApiPostCall({ relatedQueryKeys: ['VacationMode'] }) @@ -29,11 +31,13 @@ export const CippWizardVacationConfirmation = (props) => { const tenantFilter = values.tenantFilter?.value || values.tenantFilter const isSubmitting = caExclusion.isPending || + auditExclusion.isPending || mailboxVacation.isPending || forwardingVacation.isPending || oooVacation.isPending const hasSubmitted = caExclusion.isSuccess || + auditExclusion.isSuccess || mailboxVacation.isSuccess || forwardingVacation.isSuccess || oooVacation.isSuccess @@ -54,7 +58,6 @@ export const CippWizardVacationConfirmation = (props) => { vacation: true, reference: values.reference || null, postExecution: values.postExecution || [], - excludeLocationAuditAlerts: values.excludeLocationAuditAlerts || false, // Only send the travel policy fields on the first request so the // temporary policy is scheduled once, not once per selected CA policy ...(index === 0 && createTravelPolicy @@ -68,6 +71,20 @@ export const CippWizardVacationConfirmation = (props) => { }) } + if (values.excludeLocationAuditAlerts) { + auditExclusion.mutate({ + url: '/api/ExecScheduleAuditExclusionVacation', + data: { + tenantFilter, + Users: values.Users, + startDate: values.startDate, + endDate: values.endDate, + reference: values.reference || null, + postExecution: values.postExecution || [], + }, + }) + } + if (values.enableMailboxPermissions) { mailboxVacation.mutate({ url: '/api/ExecScheduleMailboxVacation', @@ -225,6 +242,7 @@ export const CippWizardVacationConfirmation = (props) => { {(() => { const enabledCount = [ values.enableCAExclusion, + values.excludeLocationAuditAlerts, values.enableMailboxPermissions, values.enableForwarding, values.enableOOO, @@ -254,13 +272,6 @@ export const CippWizardVacationConfirmation = (props) => { : 'Not selected'} - {values.excludeLocationAuditAlerts && ( -
    - - Location-based audit log alerts will be excluded - -
    - )} {values.createTravelPolicy && (
    @@ -284,6 +295,24 @@ export const CippWizardVacationConfirmation = (props) => { )} + {values.excludeLocationAuditAlerts && ( + + + } + /> + + + + The users are excluded from location-based audit log alerts between the start + and end date. + + + + + )} + {values.enableMailboxPermissions && ( @@ -434,18 +463,13 @@ export const CippWizardVacationConfirmation = (props) => { {/* API Results */} {values.enableCAExclusion && } + {values.excludeLocationAuditAlerts && } {values.enableMailboxPermissions && } {values.enableForwarding && } {values.enableOOO && } {/* Navigation + Custom Submit */} - + {currentStep > 0 && ( )} - + ) } diff --git a/src/components/CippWizard/wizard-steps.js b/src/components/CippWizard/wizard-steps.js index 67b79a654105..bfdc473a7e51 100644 --- a/src/components/CippWizard/wizard-steps.js +++ b/src/components/CippWizard/wizard-steps.js @@ -1,5 +1,7 @@ import PropTypes from "prop-types"; import CheckIcon from "@heroicons/react/24/outline/CheckIcon"; +import { useIsMobileLayout } from "../../hooks/use-breakpoint"; +import { CippWizardProgressHeader } from "./CippWizardProgressHeader"; import { Box, Step, @@ -137,6 +139,14 @@ const WizardStepIcon = (props) => { export const WizardSteps = (props) => { const { activeStep = 1, orientation = "vertical", steps = [] } = props; + const isMobile = useIsMobileLayout(); + + // Only the horizontal stepper is wizard navigation. The vertical one is a status list — + // GDAP onboarding feeds it server-side steps where each step's message and pass/fail + // state IS the content, so collapsing it to a progress bar would delete that. + if (isMobile && orientation === "horizontal") { + return ; + } return (
    @@ -145,8 +155,10 @@ export const WizardSteps = (props) => { activeStep={activeStep} connector={} > - {steps.map((step) => ( - + {/* Onboarding's steps carry only a description, so keying on title alone made + every key undefined and reconciliation index-driven by accident. */} + {steps.map((step, index) => ( + device.isEncrypted === true).length, + // Cloud PCs never report BitLocker but are platform-encrypted by Azure. + value: deviceData.filter( + (device) => device.isEncrypted === true || isCloudPcDevice(device), + ).length, label: 'Encrypted', }, ]} @@ -1622,6 +1628,10 @@ export const ExecutiveReportButton = (props) => { setPreviewOpen(false) } + // Below md the 320px config rail would leave the preview about 70px wide, so it moves into + // a drawer and the preview takes the whole dialog. + const [sectionsOpen, setSectionsOpen] = useState(false) + // Section configuration options const sectionOptions = [ { @@ -1671,6 +1681,102 @@ export const ExecutiveReportButton = (props) => { }, ] + // One definition, two homes: the desktop rail and the mobile drawer. The drawer's own + // header already says "Report Sections", so it takes the panel without the heading. + const sectionPanel = ({ showHeading = true } = {}) => ( + + {showHeading && ( + + + Report Sections + + )} + + Configure which sections to include in your executive report. Changes are reflected in + real-time. + + + + option.value === brandingPresetId) ?? presetOptions[0] + } + onChange={(option) => setPresetOverride(option?.value ?? '')} + /> + + Presets are managed in Settings → Branding + + + + + {sectionOptions.map((option) => ( + handleSectionToggle(option.key)} + sx={{ + p: 1.5, + border: '1px solid', + borderColor: sectionConfig[option.key] ? 'primary.main' : 'divider', + bgcolor: sectionConfig[option.key] ? 'primary.50' : 'background.paper', + cursor: 'pointer', + transition: 'all 0.2s ease-in-out', + display: 'flex', + alignItems: 'center', + '&:hover': { + borderColor: 'primary.main', + bgcolor: sectionConfig[option.key] ? 'primary.100' : 'primary.25', + }, + }} + > + { + event.stopPropagation() + handleSectionToggle(option.key) + }} + onClick={(event) => event.stopPropagation()} + color="primary" + size="small" + disabled={ + sectionConfig[option.key] && + Object.values(sectionConfig).filter(Boolean).length === 1 + } + /> + + + {option.label} + + + {option.description} + + + + ))} + + + + + 💡 Pro Tip + + + Enable only the sections relevant to your audience to create focused, impactful reports. + At least one section must be enabled. + + + + ) + return ( <> {/* Main Executive Summary Button - Always available */} @@ -1742,8 +1848,9 @@ export const ExecutiveReportButton = (props) => { fullWidth sx={{ '& .MuiDialog-paper': { - height: '95vh', - maxHeight: '95vh', + // dvh, not vh: iOS counts the collapsing address bar in vh, so 95vh overflows. + height: { xs: '100dvh', md: '95vh' }, + maxHeight: { xs: '100dvh', md: '95vh' }, }, }} > @@ -1757,16 +1864,28 @@ export const ExecutiveReportButton = (props) => { borderColor: 'divider', }} > - + Executive Report - {tenantName} - - - + + {/* The config rail's stand-in below md, in the title bar because the dialog is + full-screen there and this is the only chrome that stays put. */} + setSectionsOpen(true)} + size="small" + aria-label="Report sections" + sx={{ display: { xs: 'inline-flex', md: 'none' } }} + > + + + + + + - {/* Left Panel - Section Configuration */} + {/* Left Panel - Section Configuration. Below md it lives in the drawer instead. */} { borderColor: 'divider', height: '100%', overflow: 'auto', + display: { xs: 'none', md: 'block' }, }} > - - - - Report Sections - - - Configure which sections to include in your executive report. Changes are reflected - in real-time. - - - - option.value === brandingPresetId) ?? - presetOptions[0] - } - onChange={(option) => setPresetOverride(option?.value ?? '')} - /> - - Presets are managed in Settings → Branding - - - - - {sectionOptions.map((option) => ( - handleSectionToggle(option.key)} - sx={{ - p: 1.5, - border: '1px solid', - borderColor: sectionConfig[option.key] ? 'primary.main' : 'divider', - bgcolor: sectionConfig[option.key] ? 'primary.50' : 'background.paper', - cursor: 'pointer', - transition: 'all 0.2s ease-in-out', - display: 'flex', - alignItems: 'center', - '&:hover': { - borderColor: 'primary.main', - bgcolor: sectionConfig[option.key] ? 'primary.100' : 'primary.25', - }, - }} - > - { - event.stopPropagation() - handleSectionToggle(option.key) - }} - onClick={(event) => event.stopPropagation()} - color="primary" - size="small" - disabled={ - sectionConfig[option.key] && - Object.values(sectionConfig).filter(Boolean).length === 1 - } - /> - - - {option.label} - - - {option.description} - - - - ))} - - - - - 💡 Pro Tip - - - Enable only the sections relevant to your audience to create focused, impactful - reports. At least one section must be enabled. - - - + {sectionPanel()} {/* Right Panel - PDF Preview */} - + {isDataLoading ? ( { justifyContent: 'center', height: '100%', gap: 2, + // Gutters and a measure: this pane is the full width of the screen below md, + // where the second line is long enough to run edge to edge and break badly. + px: 3, + textAlign: 'center', }} > Loading Report Data... - + Fetching additional data for comprehensive report generation ) : reportDocument ? ( - { showToolbar={true} > {reportDocument} - + ) : ( { - + :not(style) ~ :not(style)': { ml: { xs: 0, md: 1 } }, + }} + > Sections enabled: {Object.values(sectionConfig).filter(Boolean).length} of{' '} @@ -2000,6 +2036,19 @@ export const ExecutiveReportButton = (props) => { Close + + {/* Mounted inside the Dialog so it inherits its theme scope; aboveModal lifts it over + the dialog it is opened from. */} + setSectionsOpen(false)} + title="Report Sections" + size="sm" + contentPadding={0} + aboveModal + > + {sectionPanel({ showHeading: false })} + ) diff --git a/src/components/ReleaseNotesDialog.js b/src/components/ReleaseNotesDialog.js index 0ad27744fbea..08811b2286f6 100644 --- a/src/components/ReleaseNotesDialog.js +++ b/src/components/ReleaseNotesDialog.js @@ -10,6 +10,12 @@ } from 'react' import { Box, + ButtonBase, + IconButton, + List, + ListItemButton, + ListItemIcon, + ListItemText, Button, CircularProgress, Dialog, @@ -20,14 +26,18 @@ import { Stack, Typography, } from '@mui/material' +import { visuallyHidden } from '@mui/utils' import ReactMarkdown from 'react-markdown' +import { useHistoryDismiss } from '../hooks/use-history-dismiss' +import { CippBottomSheet } from './CippComponents/CippBottomSheet' +import { useIsMobileLayout } from '../hooks/use-breakpoint' import remarkGfm from 'remark-gfm' import remarkParse from 'remark-parse' import rehypeRaw from 'rehype-raw' import { unified } from 'unified' import packageInfo from '../../public/version.json' import { ApiGetCall } from '../api/ApiCall' -import { GitHub } from '@mui/icons-material' +import { Check, Close, GitHub, KeyboardArrowDown, MoreHoriz } from '@mui/icons-material' import { CippAutoComplete } from './CippComponents/CippAutocomplete' const RELEASE_COOKIE_KEY = 'cipp_release_notice' @@ -134,22 +144,41 @@ class MarkdownErrorBoundary extends Component { } } +// Which release the dialog *shows*. Hotfix and maintenance builds (v10.8.1, v10.8.2) carry +// only the delta since the feature release, so opening on one tells the user almost nothing +// about what changed. Default to the newest vX.Y.0 instead; the picker still lists every +// release, and dismissal keeps tracking the exact running tag (see buildReleaseMetadata) so +// this can't reintroduce the dialog-reopens-forever bug. +const isFeatureRelease = (tag) => /^v?\d+\.\d+\.0$/.test(String(tag ?? '')) + +const pickDisplayRelease = (catalog, releaseMeta) => + catalog.find((release) => isFeatureRelease(release.releaseTag)) || + catalog.find((release) => release.releaseTag === releaseMeta.releaseTag) || + catalog.find((release) => release.releaseTag === releaseMeta.baseTag) || + catalog[0] + export const ReleaseNotesDialog = forwardRef((_props, ref) => { const releaseMeta = useMemo(() => buildReleaseMetadata(packageInfo.version), []) const [isEligible, setIsEligible] = useState(false) const [open, setOpen] = useState(false) const [isExpanded, setIsExpanded] = useState(false) const [manualOpenRequested, setManualOpenRequested] = useState(false) - const [selectedReleaseTag, setSelectedReleaseTag] = useState(releaseMeta.baseTag) + const [moreActionsOpen, setMoreActionsOpen] = useState(false) + const [releasePickerOpen, setReleasePickerOpen] = useState(false) + // Left unset until the catalog loads so pickDisplayRelease chooses; seeding it with + // the running tag meant a hotfix build always displayed its own thin release notes. + const [selectedReleaseTag, setSelectedReleaseTag] = useState(null) const hasOpenedRef = useRef(false) + const isMobile = useIsMobileLayout() useEffect(() => { hasOpenedRef.current = false }, [releaseMeta.releaseTag]) useEffect(() => { - setSelectedReleaseTag(releaseMeta.baseTag) - }, [releaseMeta.baseTag]) + // New build -> re-pick from the catalog rather than pinning to this build's tag + setSelectedReleaseTag(null) + }, [releaseMeta.releaseTag]) useEffect(() => { if (typeof window === 'undefined') { @@ -191,17 +220,14 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { } if (!selectedReleaseTag) { - setSelectedReleaseTag(releaseCatalog[0].releaseTag) + setSelectedReleaseTag(pickDisplayRelease(releaseCatalog, releaseMeta)?.releaseTag) return } const hasSelected = releaseCatalog.some((release) => release.releaseTag === selectedReleaseTag) if (!hasSelected) { - const fallbackRelease = - releaseCatalog.find((release) => release.releaseTag === releaseMeta.releaseTag) || - releaseCatalog.find((release) => release.releaseTag === releaseMeta.baseTag) || - releaseCatalog[0] + const fallbackRelease = pickDisplayRelease(releaseCatalog, releaseMeta) if (fallbackRelease) { setSelectedReleaseTag(fallbackRelease.releaseTag) } @@ -211,7 +237,13 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { const releaseOptions = useMemo(() => { const mapped = releaseCatalog.map((release) => { const tag = release.releaseTag ?? release.tagName - const label = release.name ? `${release.name} (${tag})` : tag + // GitHub release names usually start with the tag ("v10.8.0 - Ramos Melon Fizz"), + // so the parenthetical only earns its width when the name doesn't carry it. + const label = release.name + ? release.name.includes(tag) + ? release.name + : `${release.name} (${tag})` + : tag return { label, value: tag, @@ -307,6 +339,10 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { setManualOpenRequested(false) } + // Phone back gesture dismisses the dialog instead of navigating the page away — same + // remind-later semantics as the ✕, the backdrop and Esc. + useHistoryDismiss(open, handleRemindLater, isMobile) + const toggleExpanded = () => { setIsExpanded((prev) => !prev) } @@ -367,33 +403,72 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { }, }} > - - + {isMobile ? ( + setReleasePickerOpen(true)} + aria-haspopup="dialog" + sx={{ + minWidth: 0, + flex: 1, + display: 'flex', + alignItems: 'center', + gap: 0.5, + borderRadius: 1, + textAlign: 'left', + justifyContent: 'flex-start', + }} + > + + {selectedReleaseValue?.label ?? 'Release notes'} + + + switch release + + + + ) : ( + + + {`Release notes for ${releaseHeading}`} + + + + + )} + {/* Phones drop the "Remind me next time" button — closing IS remind-later + (onClose runs the same handler) — so the ✕ is the visible way to do it. */} + - - {`Release notes for ${releaseHeading}`} - - - - + + @@ -423,8 +498,25 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { { - - + - - + + - + setMoreActionsOpen(true)} + sx={{ + display: { xs: 'inline-flex', md: 'none' }, + minWidth: 44, + minHeight: 44, + border: 1, + borderColor: 'divider', + borderRadius: 1, + }} + > + + + + setReleasePickerOpen(false)} + title="Release" + > + + {releaseOptions.map((option) => { + const selected = option.value === selectedReleaseTag + return ( + { + setReleasePickerOpen(false) + if (!selected) handleReleaseChange(option) + }} + > + + {selected && } + + ) + })} + + + setMoreActionsOpen(false)} + title="Release notes" + > + + setMoreActionsOpen(false)} + sx={{ minHeight: 48 }} + > + + + + + + { + setMoreActionsOpen(false) + handleDismissPermanently() + }} + sx={{ minHeight: 48 }} + > + + + + + + + ) }) diff --git a/src/components/ReportBuilder/ReportBuilderPDF.js b/src/components/ReportBuilder/ReportBuilderPDF.js index 8ed9160b9867..d0f0a85235e2 100644 --- a/src/components/ReportBuilder/ReportBuilderPDF.js +++ b/src/components/ReportBuilder/ReportBuilderPDF.js @@ -1,5 +1,6 @@ import { useMemo } from 'react' -import { Text, View, StyleSheet, PDFViewer } from '@react-pdf/renderer' +import { Text, View, StyleSheet } from '@react-pdf/renderer' +import { CippPdfPreview } from '../CippPdf/CippPdfPreview' import { ContentPage, DEFAULT_PAGE_SETUP, @@ -650,9 +651,15 @@ export const ReportBuilderPDF = ({ if (mode === 'preview') { return ( - + {document} - + ) } return null diff --git a/src/components/ShadowAIReportButton.js b/src/components/ShadowAIReportButton.js index 54153c42a0bc..82b13955ca47 100644 --- a/src/components/ShadowAIReportButton.js +++ b/src/components/ShadowAIReportButton.js @@ -15,7 +15,8 @@ import { Typography, } from '@mui/material' import { Close, Download, PictureAsPdf, Settings } from '@mui/icons-material' -import { PDFViewer } from '@react-pdf/renderer' +import { CippPdfPreview } from './CippPdf/CippPdfPreview' +import { CippOffCanvas } from './CippComponents/CippOffCanvas' import { useReportVariables } from './CippPdf/useReportVariables' import { useBrandingSettings } from './CippPdf/useBrandingSettings' import { @@ -576,6 +577,9 @@ export const ShadowAIReportButton = ({ data, tenantName, disabled }) => { const brandingSettings = useBrandingSettings() const variables = useReportVariables() const [previewOpen, setPreviewOpen] = useState(false) + // Below md the 320px config rail would leave the preview about 70px wide, so it moves into + // a drawer and the preview takes the whole dialog. Same treatment as the executive report. + const [sectionsOpen, setSectionsOpen] = useState(false) const [sectionConfig, setSectionConfig] = useState({ coverPage: true, executiveSummary: true, @@ -603,6 +607,73 @@ export const ShadowAIReportButton = ({ data, tenantName, disabled }) => { new Date().toISOString().split('T')[0] }.pdf` + // One definition, two homes: the desktop rail and the mobile drawer. The drawer's own + // header already says "Report Sections", so it takes the panel without the heading. + const sectionPanel = ({ showHeading = true } = {}) => ( + + {showHeading && ( + + + Report Sections + + )} + + Configure which sections to include in your Shadow AI report. Changes are reflected in + real-time. + + + + {sectionOptions.map((option) => ( + handleSectionToggle(option.key)} + sx={{ + p: 1.5, + border: '1px solid', + borderColor: sectionConfig[option.key] ? 'primary.main' : 'divider', + bgcolor: sectionConfig[option.key] ? 'primary.50' : 'background.paper', + cursor: 'pointer', + transition: 'all 0.2s ease-in-out', + display: 'flex', + alignItems: 'center', + '&:hover': { + borderColor: 'primary.main', + bgcolor: sectionConfig[option.key] ? 'primary.100' : 'primary.25', + }, + }} + > + { + event.stopPropagation() + handleSectionToggle(option.key) + }} + onClick={(event) => event.stopPropagation()} + color="primary" + size="small" + disabled={ + sectionConfig[option.key] && + Object.values(sectionConfig).filter(Boolean).length === 1 + } + /> + + + {option.label} + + + {option.description} + + + + ))} + + + ) + const reportDocument = useMemo(() => { if (!previewOpen) return null return ( @@ -642,7 +713,13 @@ export const ShadowAIReportButton = ({ data, tenantName, disabled }) => { onClose={() => setPreviewOpen(false)} maxWidth="xl" fullWidth - sx={{ '& .MuiDialog-paper': { height: '95vh', maxHeight: '95vh' } }} + sx={{ + '& .MuiDialog-paper': { + // dvh, not vh: iOS counts the collapsing address bar in vh, so 95vh overflows. + height: { xs: '100dvh', md: '95vh' }, + maxHeight: { xs: '100dvh', md: '95vh' }, + }, + }} > { borderColor: 'divider', }} > - + Shadow AI Report - {tenantName} - setPreviewOpen(false)} size="small"> - - + + {/* The config rail's stand-in below md, in the title bar because the dialog is + full-screen there and this is the only chrome that stays put. */} + setSectionsOpen(true)} + size="small" + aria-label="Report sections" + sx={{ display: { xs: 'inline-flex', md: 'none' } }} + > + + + setPreviewOpen(false)} + size="small" + aria-label="Close preview" + > + + + - {/* Left Panel - Section Configuration */} + {/* Left Panel - Section Configuration. Below md it lives in the drawer instead. */} { borderColor: 'divider', height: '100%', overflow: 'auto', + display: { xs: 'none', md: 'block' }, }} > - - - - Report Sections - - - Configure which sections to include in your Shadow AI report. Changes are reflected - in real-time. - - - - {sectionOptions.map((option) => ( - handleSectionToggle(option.key)} - sx={{ - p: 1.5, - border: '1px solid', - borderColor: sectionConfig[option.key] ? 'primary.main' : 'divider', - bgcolor: sectionConfig[option.key] ? 'primary.50' : 'background.paper', - cursor: 'pointer', - transition: 'all 0.2s ease-in-out', - display: 'flex', - alignItems: 'center', - '&:hover': { - borderColor: 'primary.main', - bgcolor: sectionConfig[option.key] ? 'primary.100' : 'primary.25', - }, - }} - > - { - event.stopPropagation() - handleSectionToggle(option.key) - }} - onClick={(event) => event.stopPropagation()} - color="primary" - size="small" - disabled={ - sectionConfig[option.key] && - Object.values(sectionConfig).filter(Boolean).length === 1 - } - /> - - - {option.label} - - - {option.description} - - - - ))} - - + {sectionPanel()} {/* Right Panel - PDF Preview */} - + {reportDocument && ( - {reportDocument} - + )} - + :not(style) ~ :not(style)': { ml: { xs: 0, md: 1 } }, + }} + > Sections enabled: {Object.values(sectionConfig).filter(Boolean).length} of{' '} @@ -803,6 +844,19 @@ export const ShadowAIReportButton = ({ data, tenantName, disabled }) => { Close + + {/* Mounted inside the Dialog so it inherits its theme scope; aboveModal lifts it over + the dialog it is opened from. */} + setSectionsOpen(false)} + title="Report Sections" + size="sm" + contentPadding={0} + aboveModal + > + {sectionPanel({ showHeading: false })} + ) diff --git a/src/components/actions-menu.js b/src/components/actions-menu.js index 77a4c1c6a6cc..19f4ec9f2d66 100644 --- a/src/components/actions-menu.js +++ b/src/components/actions-menu.js @@ -2,25 +2,17 @@ import ChevronDownIcon from "@heroicons/react/24/outline/ChevronDownIcon"; import PropTypes from "prop-types"; import { Button, ListItemText, Menu, MenuItem, SvgIcon } from "@mui/material"; import { usePopover } from "../hooks/use-popover"; -import { useState } from "react"; -import { useDialog } from "../hooks/use-dialog"; -import { CippApiDialog } from "./CippComponents/CippApiDialog"; +import { useActionsDispatch } from "../hooks/use-actions-dispatch"; export const ActionsMenu = (props) => { const { actions = [], label = "Actions", data, queryKeys, ...other } = props; const popover = usePopover(); - const [actionData, setActionData] = useState({ data: {}, action: {}, ready: false }); - const createDialog = useDialog(); - const handleActionDisabled = (row, action) => { - //add nullsaftey for row. It can sometimes be undefined(still loading) or null(no data) - if (!row) { - return true; - } - if (action?.condition) { - return !action?.condition(row); - } - return false; - }; + const { visibleActions, isDisabled, dispatch, dialog } = useActionsDispatch({ + actions, + data, + queryKeys, + }); + return ( <> { vertical: "top", }} > - {actions - ?.filter((action) => !action.link || action.showInActionsMenu) - .map((action, index) => ( - { - setActionData({ - data: data, - action: action, - ready: true, - }); - - if (action?.noConfirm && action.customFunction) { - action.customFunction(data, action, {}); - popover.handleClose(); - } else { - createDialog.handleOpen(); - popover.handleClose(); - } - }} - > - - {action.icon} - - {action.label} - - ))} + {visibleActions.map((action, index) => ( + { + dispatch(action); + popover.handleClose(); + }} + > + + {action.icon} + + {action.label} + + ))} - {actionData.ready && ( - - )} + {dialog} ); }; diff --git a/src/components/images-dialog.js b/src/components/images-dialog.js index 71668979d5db..d17c6d1cad8f 100644 --- a/src/components/images-dialog.js +++ b/src/components/images-dialog.js @@ -92,7 +92,7 @@ export const ImagesDialog = (props) => { onDrop={handleDrop} sx={{ mb: 3 }} /> - https://huntresslabs.github.io/rogueapps/. CIPP also has a list of community collected rogue apps.", + "description": "Huntress has provided a repository of known rogue apps that are commonly used in BEC, data exfiltration and other Microsoft 365 attacks. This alert will notify you if any of these apps are detected in the selected tenant(s). For more information, see https://huntresslabs.github.io/rogueapps/. CIPP also maintains its own curated list of rogue apps, so detections may include applications that are not on the Huntress site. See the CIPP documentation for the full list.", "requiresInput": true, "inputType": "switch", "inputLabel": "Ignore Disabled Apps?", diff --git a/src/data/cipp-roles.json b/src/data/cipp-roles.json index ac3c389f65a2..750449ac13a6 100644 --- a/src/data/cipp-roles.json +++ b/src/data/cipp-roles.json @@ -18,7 +18,8 @@ "CIPP.SuperAdmin.*", "CIPP.Admin.*", "CIPP.AppSettings.*", - "Tenant.Standards.ReadWrite" + "Tenant.Standards.ReadWrite", + "Tenant.Baselines.ReadWrite" ] }, "admin": { diff --git a/src/data/standards.json b/src/data/standards.json index ac06341b8b8c..d4a68dc0e98c 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -1212,15 +1212,55 @@ "cat": "Entra (AAD) Standards", "tag": [], "appliesToTest": ["EIDSCAAT01", "EIDSCAAT02", "ZTNA21845", "ZTNA21846"], - "helpText": "Enables TAP and sets the default TAP lifetime to 1 hour. This configuration also allows you to select if a TAP is single use or multi-logon.", + "helpText": "Enable TAP with the specified configuration settings.", "docsDescription": "Enables Temporary Access Pass generation for the tenant.", - "executiveText": "Enables temporary access passes that IT administrators can generate for employees who are locked out or need emergency access to systems. These time-limited passs provide a secure way to restore access without compromising long-term security policies.", + "executiveText": "Enables temporary access passes that IT administrators can generate for employees who are locked out or need emergency access to systems. These time-limited passes provide a secure way to restore access without compromising long-term security policies.", "addedComponent": [ + { + "type": "number", + "name": "standards.TAP.MinimumLifetime", + "label": "Minimum Lifetime (minutes)", + "defaultValue": 60, + "validators": { + "min": { "value": 10, "message": "Minimum value is 10" }, + "max": { "value": 43200, "message": "Maximum value is 43200" } + } + }, + { + "type": "number", + "name": "standards.TAP.MaximumLifetime", + "label": "Maximum Lifetime (minutes)", + "defaultValue": 480, + "validators": { + "min": { "value": 10, "message": "Minimum value is 10" }, + "max": { "value": 43200, "message": "Maximum value is 43200" } + } + }, + { + "type": "number", + "name": "standards.TAP.DefaultLifetime", + "label": "Default Lifetime (minutes)", + "defaultValue": 60, + "validators": { + "min": { "value": 10, "message": "Minimum value is 10" }, + "max": { "value": 43200, "message": "Maximum value is 43200" } + } + }, + { + "type": "number", + "name": "standards.TAP.TAPLength", + "label": "Length (characters)", + "defaultValue": 8, + "validators": { + "min": { "value": 8, "message": "Minimum value is 8" }, + "max": { "value": 48, "message": "Maximum value is 48" } + } + }, { "type": "autoComplete", "multiple": false, "creatable": false, - "label": "Select TAP Lifetime", + "label": "Number of Times Usable", "name": "standards.TAP.config", "options": [ { "label": "Only Once", "value": "true" }, @@ -1353,14 +1393,22 @@ "ZTNA21809", "ZTNA21869" ], - "helpText": "Enables App consent admin requests for the tenant via the GA role. Does not overwrite existing reviewer settings", - "docsDescription": "Enables the ability for users to request admin consent for applications. Should be used in conjunction with the \"Require admin consent for applications\" standards", + "helpText": "Enables App consent admin requests for the tenant via the GA role. Optionally adds specific users (matched by display name) as reviewers. Does not overwrite existing reviewer settings", + "docsDescription": "Enables the ability for users to request admin consent for applications. Reviewers can be directory roles and/or specific users matched by display name, e.g. a central MSP support account that exists as a guest in each tenant, so each consent request generates a notification to a monitored mailbox. Should be used in conjunction with the \"Require admin consent for applications\" standards", "executiveText": "Establishes a formal approval process where employees can request access to business applications that require administrative review. This balances security with productivity by allowing controlled access to necessary tools while preventing unauthorized application installations.", "addedComponent": [ { "type": "AdminRolesMultiSelect", "label": "App Consent Reviewer Roles", "name": "standards.EnableAppConsentRequests.ReviewerRoles" + }, + { + "type": "autoComplete", + "multiple": true, + "creatable": true, + "required": false, + "label": "Optional: reviewer users (display names of existing users or guests)", + "name": "standards.EnableAppConsentRequests.ReviewerUsers" } ], "label": "Enable App consent admin requests", @@ -1387,7 +1435,8 @@ "name": "standards.NudgeMFA.state", "options": [ { "label": "Enabled", "value": "enabled" }, - { "label": "Disabled", "value": "disabled" } + { "label": "Disabled", "value": "disabled" }, + { "label": "Microsoft managed", "value": "default" } ] }, { @@ -3429,21 +3478,38 @@ "name": "standards.QuarantineRequestAlert", "cat": "Defender Standards", "tag": [], - "helpText": "Sets a e-mail address to alert when a User requests to release a quarantined message.", - "docsDescription": "Sets a e-mail address to alert when a User requests to release a quarantined message. This is useful for monitoring and ensuring that the correct messages are released.", + "helpText": "Sets a e-mail address to alert when a User requests to release a quarantined message. Set the alert state to Removed to delete the alert rule CIPP created from the tenant.", + "docsDescription": "Sets a e-mail address to alert when a User requests to release a quarantined message. This is useful for monitoring and ensuring that the correct messages are released. Setting the alert state to Removed deletes the alert rule CIPP created from the tenant, for when the alert is no longer wanted.", "executiveText": "Notifies IT administrators when employees request to release emails that were quarantined for security reasons, enabling oversight of potentially dangerous messages. This helps ensure that legitimate emails are released while maintaining security controls over suspicious content.", "addedComponent": [ + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "required": false, + "label": "Alert state (blank or Enabled creates the alert, Removed deletes it)", + "name": "standards.QuarantineRequestAlert.state", + "options": [ + { "label": "Enabled", "value": "enabled" }, + { "label": "Removed", "value": "removed" } + ] + }, { "type": "textField", "name": "standards.QuarantineRequestAlert.NotifyUser", - "label": "E-mail to receive the alert" + "label": "E-mail to receive the alert", + "condition": { + "field": "standards.QuarantineRequestAlert.state", + "compareType": "isNot", + "compareValue": { "label": "Removed", "value": "removed" } + } } ], "label": "Quarantine Release Request Alert", "impact": "Low Impact", "impactColour": "info", "addedDate": "2024-07-15", - "powershellEquivalent": "New-ProtectionAlert and Set-ProtectionAlert", + "powershellEquivalent": "New-ProtectionAlert, Set-ProtectionAlert and Remove-ProtectionAlert", "recommendedBy": [], "requiredCapabilities": [ "EXCHANGE_S_STANDARD", @@ -6021,6 +6087,16 @@ "type": "switch", "name": "standards.TeamsExternalAccessPolicy.EnableTeamsConsumerAccess", "label": "Allow communication with unmanaged Teams accounts" + }, + { + "type": "switch", + "name": "standards.TeamsExternalAccessPolicy.EnableTeamsConsumerInbound", + "label": "Allow unmanaged Teams users to initiate contact", + "condition": { + "field": "standards.TeamsExternalAccessPolicy.EnableTeamsConsumerAccess", + "compareType": "is", + "compareValue": true + } } ], "label": "External Access Settings for Microsoft Teams", @@ -6045,6 +6121,16 @@ "name": "standards.TeamsFederationConfiguration.AllowTeamsConsumer", "label": "Allow users to communicate with consumer Teams accounts" }, + { + "type": "switch", + "name": "standards.TeamsFederationConfiguration.AllowTeamsConsumerInbound", + "label": "Allow unmanaged Teams users to initiate contact", + "condition": { + "field": "standards.TeamsFederationConfiguration.AllowTeamsConsumer", + "compareType": "is", + "compareValue": true + } + }, { "type": "autoComplete", "required": true, @@ -6463,6 +6549,68 @@ "recommendedBy": [], "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] }, + { + "name": "standards.AppleEnrollmentTypeProfile", + "cat": "Intune Standards", + "tag": ["enrollment", "apple", "ios"], + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, + "helpText": "Creates and manages an Apple user-initiated enrollment type profile (such as iOS/iPadOS web based device enrollment) and keeps it assigned to the configured groups. The tenant needs an Apple MDM push certificate for the enrollment itself to function.", + "executiveText": "Ensures every tenant offers the same enrollment experience for Apple devices, such as web based enrollment for personal iPhones and iPads, without engineers configuring each tenant by hand. This keeps device onboarding consistent and makes it possible to report on which tenants are correctly configured.", + "docsDescription": "Deploys an Apple user-initiated enrollment type profile through deviceManagement/appleUserInitiatedEnrollmentProfiles. The profile is matched by display name; the enrollment type (web based device enrollment, account driven user enrollment, or device enrollment with Company Portal), description and group assignments are kept in sync, with a wrong assignment repaired in place. Priority is only applied when the profile is first created, because reordering is relative to the other profiles in each tenant.", + "addedComponent": [ + { + "type": "textField", + "name": "standards.AppleEnrollmentTypeProfile.DisplayName", + "label": "Profile Display Name", + "required": true + }, + { + "type": "textField", + "name": "standards.AppleEnrollmentTypeProfile.Description", + "label": "Profile Description", + "required": false + }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "name": "standards.AppleEnrollmentTypeProfile.EnrollmentType", + "label": "Enrollment Type", + "options": [ + { "label": "Web based device enrollment", "value": "webDeviceEnrollment" }, + { "label": "Account driven user enrollment", "value": "accountDrivenUserEnrollment" }, + { "label": "Device enrollment with Company Portal", "value": "device" } + ] + }, + { + "type": "number", + "name": "standards.AppleEnrollmentTypeProfile.Priority", + "label": "Priority (applied when the profile is created)", + "defaultValue": 1 + }, + { + "type": "radio", + "name": "standards.AppleEnrollmentTypeProfile.AssignTo", + "label": "Profile Assignment", + "options": [ + { "label": "Do not assign", "value": "none" }, + { "label": "Assign to Custom Group", "value": "customGroup" } + ] + }, + { + "type": "textField", + "name": "standards.AppleEnrollmentTypeProfile.customGroup", + "label": "Custom group name(s). Comma separated, wildcards allowed.", + "required": false + } + ], + "label": "Deploy Apple Enrollment Type Profile", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-08-18", + "recommendedBy": [], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] + }, { "name": "standards.IntuneTemplate", "cat": "Templates", @@ -6939,7 +7087,8 @@ "label": "Select Sensitivity Label Templates", "api": { "url": "/api/ListSensitivityLabelTemplates", - "labelField": "name", + "labelField": "DisplayName", + "altLabelField": "Name", "valueField": "GUID", "queryKey": "ListSensitivityLabelTemplates" } @@ -7655,6 +7804,28 @@ "EXCHANGE_LITE" ] }, + { + "name": "standards.MessageEncryption", + "cat": "Exchange Standards", + "tag": [], + "helpText": "Enables Microsoft Purview Message Encryption by turning on Azure RMS licensing for Exchange Online. Skipped with a warning when the tenant still points at an on-premises AD RMS cluster, because AD RMS has to be migrated to Azure RMS first. This standard only turns the feature on: branding, one-time passcodes, and social ID sign-in for encrypted messages are configured in the [Configure Encrypted Message Branding (OME)](https://standards.cipp.app/standards/omebranding) standard. [Read more](https://learn.microsoft.com/en-us/purview/set-up-new-message-encryption-capabilities)", + "docsDescription": "Sets AzureRMSLicensingEnabled to true, the only prerequisite for Microsoft Purview Message Encryption. Reports the IRM licensing state per tenant, including the licensing location, so you can see at a glance which tenants have message encryption available. Remediation is deliberately skipped for tenants with an on-premises AD RMS licensing location, as Purview Message Encryption is not compatible with AD RMS and those tenants need to be migrated to Azure RMS first.", + "executiveText": "Turns on the built-in encryption that lets staff send protected email to anyone, including recipients outside the organization. Uses licensing the organization already owns, removing the need for a separate secure-email product.", + "addedComponent": [], + "label": "Enable Purview Message Encryption", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2026-08-04", + "powershellEquivalent": "Set-IRMConfiguration -AzureRMSLicensingEnabled $true", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] + }, { "name": "standards.OMEBranding", "cat": "Exchange Standards", diff --git a/src/hooks/use-actions-dispatch.jsx b/src/hooks/use-actions-dispatch.jsx new file mode 100644 index 000000000000..bad8a7f87968 --- /dev/null +++ b/src/hooks/use-actions-dispatch.jsx @@ -0,0 +1,91 @@ +import { useCallback, useState } from "react"; +import { CippApiDialog } from "../components/CippComponents/CippApiDialog"; +import { useDialog } from "./use-dialog"; +import { useSettings } from "./use-settings"; + +const IDLE = { data: {}, action: {}, ready: false }; + +/** + * Shared dispatch for a page-level `actions` array. + * + * The desktop ActionsMenu and the mobile page-actions sheet present the same actions two + * ways; keeping the confirm-vs-run decision and the dialog wiring here is what stops the + * two presentations from drifting apart. + * + * Note the state is per-instance: two mounted consumers get two dispatchers, so this shares + * the decision, not an in-flight dialog. + */ +export const useActionsDispatch = ({ actions = [], data, queryKeys }) => { + const [actionData, setActionData] = useState(IDLE); + const [customAction, setCustomAction] = useState(null); + const createDialog = useDialog(); + const settings = useSettings(); + + // Nullsafety for data: it can be undefined (still loading) or null (no data) + const isDisabled = (action) => { + if (!data) return true; + if (action?.condition) return !action.condition(data); + return false; + }; + + const visibleActions = actions?.filter((action) => !action.link || action.showInActionsMenu) ?? []; + + const dispatch = (action) => { + // An AllTenants row carries its own tenant; posting under "AllTenants" would target the + // wrong one. Page-level data has no Tenant, so this is a no-op there. + if (settings?.currentTenant === "AllTenants" && data?.Tenant) { + settings.handleUpdate({ currentTenant: data.Tenant }); + } + + // Run-and-return paths must NOT set ready: doing so mounts CippApiDialog with + // api.noConfirm true, and its mount effect auto-submits into the very customFunction + // just called here — one tap, two invocations. + if (action?.noConfirm && action.customFunction) { + action.customFunction(data, action, {}); + return; + } + if (typeof action?.customComponent === "function") { + setCustomAction({ data, action }); + return; + } + + setActionData({ data, action, ready: true }); + createDialog.handleOpen(); + }; + + // Dropped once the close transition finishes rather than on close, so the dialog keeps its + // exit animation. Leaving it mounted would hold a live mutation, an API subscription and a + // form instance for the life of the page — and HeaderedTabbedLayout never unmounts. + const handleExited = useCallback(() => setActionData(IDLE), []); + + const dialog = ( + <> + {actionData.ready && ( + + )} + {customAction?.action?.customComponent(customAction.data, { + drawerVisible: Boolean(customAction), + setDrawerVisible: (visible) => !visible && setCustomAction(null), + fromRowAction: false, + })} + + ); + + return { visibleActions, isDisabled, dispatch, dialog }; +}; diff --git a/src/hooks/use-breakpoint.js b/src/hooks/use-breakpoint.js new file mode 100644 index 000000000000..dedf377e95a5 --- /dev/null +++ b/src/hooks/use-breakpoint.js @@ -0,0 +1,44 @@ +import { useMediaQuery } from "@mui/material"; +import { useSettings } from "./use-settings"; + +// Shared breakpoint hooks so the two mobile thresholds sit next to each other. + +// Chrome pivots where the side nav gives way to the drawer (layouts/index.js). Everything that +// has to agree with the nav reads this: content gutter, top-nav hamburger, page toolbars. +export const useIsMobileLayout = () => useMediaQuery((theme) => theme.breakpoints.down("lg")); + +// Tables pivot narrower: a table still reads fine at 1100, cards that wide are mostly whitespace. +export const useIsNarrowForTables = () => useMediaQuery((theme) => theme.breakpoints.down("md")); + +export const useIsTabletLayout = () => + useMediaQuery((theme) => theme.breakpoints.between("sm", "md")); + +// Settings values can be raw strings or {value,label} autocomplete objects depending on +// which control wrote them — accept both. +const unwrap = (setting) => (typeof setting === "object" && setting !== null ? setting.value : setting); + +const VALID_MODES = ["auto", "cards", "table"]; + +/** + * Resolves how a CippDataTable should present itself: 'cards' or 'table'. + * + * Precedence: per-call viewMode prop > settings.tableViewMode > 'auto'. + * 'auto' means cards below the md breakpoint, table at or above it. + * simple tables are always 'table' — they are 2-3 column embeds that already fit. + * + * The explicit modes exist for more than preference: jsdom has no width-based + * matchMedia, so unit tests drive card mode through this path rather than by + * stubbing media queries. + */ +export const useTableViewMode = ({ viewMode, simple = false } = {}) => { + const settings = useSettings(); + const isNarrow = useIsNarrowForTables(); + + if (simple) return "table"; + + let mode = unwrap(viewMode) ?? unwrap(settings?.tableViewMode) ?? "auto"; + if (!VALID_MODES.includes(mode)) mode = "auto"; + + if (mode === "auto") return isNarrow ? "cards" : "table"; + return mode; +}; diff --git a/src/hooks/use-history-dismiss.js b/src/hooks/use-history-dismiss.js new file mode 100644 index 000000000000..633841887496 --- /dev/null +++ b/src/hooks/use-history-dismiss.js @@ -0,0 +1,40 @@ +import { useEffect, useRef } from "react"; +import { useRouter } from "next/router"; +import { + installOverlayHistory, + pushOverlayEntry, + releaseOverlayEntry, +} from "../utils/overlay-history"; + +/** + * Gives an overlay a history entry of its own, so a phone's back gesture dismisses it + * instead of navigating the page away. + * + * useHistoryDismiss(visible, onClose, isMobile); + * + * The entry is pushed while the overlay is open and popped when it closes for any other + * reason, so the history stack is only ever as deep as what is actually on screen. + * + * Currently used by full-screen mobile surfaces (CippOffCanvas), not bottom sheets: the + * release pops synchronously, and the FAB sheet closes itself in the same tick as the + * drawer its child opens, which would let the queued back() take the drawer's entry with + * it. Registering sheets means deferring the pop so the incoming overlay can reuse the + * outgoing one's entry. + */ +export const useHistoryDismiss = (open, onClose, enabled = true) => { + const router = useRouter(); + const closeRef = useRef(onClose); + + useEffect(() => { + closeRef.current = onClose; + }, [onClose]); + + useEffect(() => { + // Nothing to hand the gesture to — an overlay with no onClose can't be dismissed, and + // claiming a history entry for it would only eat a back press. + if (!enabled || !open || typeof closeRef.current !== "function") return undefined; + installOverlayHistory(router); + const entry = pushOverlayEntry(() => closeRef.current?.()); + return () => releaseOverlayEntry(entry); + }, [enabled, open, router]); +}; diff --git a/src/hooks/use-sheet-handoff.js b/src/hooks/use-sheet-handoff.js new file mode 100644 index 000000000000..7846e22c1eae --- /dev/null +++ b/src/hooks/use-sheet-handoff.js @@ -0,0 +1,59 @@ +import { useCallback, useRef } from "react"; + +/** + * Hands a bottom sheet off to the overlay it launches. + * + * A sheet row that closes the sheet and opens a drawer/dialog in the same tick puts two + * MUI Modals in flight at once: the new one registers with the modal manager while the + * outgoing Drawer is still transitioning, and when that Drawer finally unmounts it + * restores scroll lock, focus and aria-hidden on top of the overlay that just opened — + * which reads as the overlay refusing to open, or opening dead. + * + * Instead, park the callback and run it from the sheet's exit transition: + * + * const sheet = useSheetHandoff(() => setOpen(false)); + * sheet.run(() => setDrawerOpen(true))} /> + * + * + * `run` still closes the sheet immediately, so the tap feels the same. + */ +// Drawer's exit is ~195ms; well past it the sheet is gone whether or not the transition +// reported in. Running late beats never running, and flush() is idempotent. +const EXIT_FALLBACK_MS = 400; + +export const useSheetHandoff = (close) => { + const pendingRef = useRef(null); + const fallbackRef = useRef(null); + + const flush = useCallback(() => { + if (fallbackRef.current) { + clearTimeout(fallbackRef.current); + fallbackRef.current = null; + } + const pending = pendingRef.current; + pendingRef.current = null; + pending?.(); + }, []); + + const run = useCallback( + (fn) => { + pendingRef.current = typeof fn === "function" ? fn : null; + if (fallbackRef.current) clearTimeout(fallbackRef.current); + fallbackRef.current = setTimeout(flush, EXIT_FALLBACK_MS); + close?.(); + }, + [close, flush] + ); + + // Dismissed without picking anything — drop whatever was parked. + const cancel = useCallback(() => { + pendingRef.current = null; + if (fallbackRef.current) { + clearTimeout(fallbackRef.current); + fallbackRef.current = null; + } + close?.(); + }, [close]); + + return { run, handleExited: flush, cancel }; +}; diff --git a/src/hooks/use-swipe-close-transition.js b/src/hooks/use-swipe-close-transition.js new file mode 100644 index 000000000000..2f83e9b25cb2 --- /dev/null +++ b/src/hooks/use-swipe-close-transition.js @@ -0,0 +1,46 @@ +import { useCallback, useEffect, useRef } from "react"; + +// Slide probes the paper's untranslated position when the exit starts (Slide.js +// getTranslateValue), so a paper carrying a drag transform snaps wide open and animates the +// full width out. Re-seed the start position with where the finger let go. +export const useSwipeCloseTransition = (open, onClose) => { + const paperRef = useRef(null); + const dragFrom = useRef(null); + + // fires as the open transition starts, so a drag that begins mid-animation still has the node + const handleEnter = useCallback((node) => { + paperRef.current = node; + }, []); + + const handleClose = useCallback( + (...args) => { + const transform = paperRef.current?.style.transform; + dragFrom.current = transform && transform !== "none" ? transform : null; + onClose?.(...args); + }, + [onClose] + ); + + // Effects flush child-first, so this lands after Slide's own exit effect, which runs the same + // probe again. Repairing from the transition's onExit callback gets overwritten by it. + useEffect(() => { + const node = paperRef.current; + const from = dragFrom.current; + dragFrom.current = null; + if (open || !node || !from) { + return; + } + const target = node.style.transform; + const transition = node.style.transition; + node.style.transition = "none"; + node.style.transform = from; + node.getBoundingClientRect(); + node.style.transition = transition; + node.style.transform = target; + }, [open]); + + return { + onClose: handleClose, + transitionProps: { onEnter: handleEnter }, + }; +}; diff --git a/src/layouts/HeaderedTabbedLayout.jsx b/src/layouts/HeaderedTabbedLayout.jsx index d217c9c87d5e..9ab87cf40918 100644 --- a/src/layouts/HeaderedTabbedLayout.jsx +++ b/src/layouts/HeaderedTabbedLayout.jsx @@ -1,11 +1,10 @@ -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import { usePathname } from "next/navigation"; import { useRouter } from "next/router"; import PropTypes from "prop-types"; import ArrowLeftIcon from "@heroicons/react/24/outline/ArrowLeftIcon"; import { Box, - Button, Container, Divider, Skeleton, @@ -16,8 +15,12 @@ import { Typography, } from "@mui/material"; import { ActionsMenu } from "../components/actions-menu"; -import { useMediaQuery } from "@mui/material"; import { getIconByName } from "../utils/icon-registry"; +import { useIsMobileLayout } from "../hooks/use-breakpoint"; +import { useActionsDispatch } from "../hooks/use-actions-dispatch"; +import { TabNavigationContext, useTabNavigationValue } from "./tab-navigation-context"; +import { CippPageActionsFab } from "../components/CippComponents/CippPageActionsFab"; +import { CippTabPicker } from "../components/CippComponents/CippTabPicker"; export const HeaderedTabbedLayout = (props) => { const { @@ -27,16 +30,23 @@ export const HeaderedTabbedLayout = (props) => { subtitle, actions, actionsData, + // Without this the dispatch falls back to CippApiDialog's hardcoded title, so a header + // action mutates successfully and never invalidates the page query. + queryKeys, isFetching = false, backUrl, + // Optional replacement for the title Typography — same slot, same truncation duties. + titleControl, } = props; - const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md")); + // The shared hook rather than an inline useMediaQuery: same threshold, but only this one is + // mockable, and jsdom has no width-based matchMedia to drive the mobile branch with. + const isMobile = useIsMobileLayout(); const router = useRouter(); const pathname = usePathname(); const queryParams = router.query; - const handleTabsChange = useCallback( - (event, value) => { + const navigateToTab = useCallback( + (value) => { //if we have query params, we need to append them to the new path router.push( { @@ -47,106 +57,221 @@ export const HeaderedTabbedLayout = (props) => { { shallow: true } ); }, - [router] + [router, queryParams] ); + const handleTabsChange = useCallback((event, value) => navigateToTab(value), [navigateToTab]); + const currentTab = tabOptions.find((option) => option.path === pathname); - return ( - - - - + // Below md the tab row scrolls horizontally and still hides tabs off the right edge, so + // navigation collapses to a picker in the title row — the one part of that row that is + // empty at this width, since the Actions menu gets clipped here and moves to the FAB. + const actionsDispatch = useActionsDispatch({ actions, data: actionsData, queryKeys }); + // No isFetching term: the desktop menu's equivalent `disabled` prop is swallowed by + // ActionsMenu's unspread ...other, so including it here greyed out every action on mobile + // during a background refetch while desktop left them clickable. Actions operate on + // stale-but-present data quite happily; aligning down keeps the two surfaces identical + // without changing desktop. + const { visibleActions, isDisabled, dispatch } = actionsDispatch; + const sheetActions = useMemo( + () => + isMobile + ? visibleActions.map((action) => ({ + label: action.label, + icon: action.icon ? {action.icon} : null, + disabled: isDisabled(action), + onClick: () => dispatch(action), + })) + : [], + [isMobile, visibleActions, isDisabled, dispatch] + ); + + const tabNavValue = useTabNavigationValue({ + tabs: tabOptions, + currentPath: pathname, + onNavigate: navigateToTab, + actions: sheetActions, + enabled: isMobile, + providesGutters: true, + }); + + const subtitleBlock = isFetching ? ( + + ) : ( + subtitle && ( + // useFlexGap: Stack's default spacing is a margin-left between children, which every + // wrapped row inherits — that margin is why the icon/chip pairs sat indented from the + // title above them. Gap applies to both axes, so the row gap is set separately or the + // stacked pairs end up as far apart vertically as they are horizontally. + + {/* minWidth: 0 down the whole chain, and flexShrink: 0 on the icon. A copy-chip + already carries MUI's ellipsis and maxWidth: 100%, but flex items default to + min-width: auto, so every ancestor grew to fit instead of letting it truncate — + which is how a guest UPN (user_domain.onmicrosoft.com#EXT#@tenant...) ran off the + right edge of the screen. */} + {subtitle.map((item, index) => + item.component ? ( + + {item.component} + + ) : ( + + {item.icon} + + + {item.text} + + + ) + )} + + ) + ); + + return ( + + + {/* One gutter for the whole page, matching the layout's breadcrumb rail + (mx: {xs: 2, md: 3}): the breadcrumbs, this header's text and the left edge of + every card below it then share a single left edge. */} + + + - {title} - - {isFetching ? ( - - ) : ( - subtitle && ( - - {subtitle.map((item, index) => - item.component ? ( - {item.component} - ) : ( - - {item.icon} - - {item.text} - - + {/* minWidth: 0 so a long tenant/entity name truncates in the space the + picker leaves rather than pushing it off the right edge of the row. + Scoped to the picker's own breakpoint — above md this is unchanged. */} + + + {/* A name-shaped skeleton, not the word "Loading...": the header is + the entity's identity, and a text placeholder reads as a title. + titleControl lets a page swap the text for an interactive control + in the same clothes (the View User pages mount a user switcher). */} + {isFetching ? ( + + + + ) : ( + titleControl ?? ( + + {title} + ) )} - ) - )} + {!isMobile && subtitleBlock} + + {/* The right half of this row is free below md, which is where the tab + picker goes. Above md it belongs to the Actions menu, as it always did. */} + {isMobile ? ( + + ) : ( + actions && + actions.length > 0 && ( + + ) + )} + + {/* Below md the subtitle gets the full width instead of sharing the title's + row: a UPN copy-chip squeezed beside a half-width picker has nowhere to go + and runs off the right edge of the screen. */} + {isMobile && subtitleBlock} - {actions && actions.length > 0 && ( - + {!isMobile && ( +
    + + {tabOptions.map((option) => { + const icon = getIconByName(option.icon, { fontSize: "small" }); + const iconPosition = option.iconPosition ?? "start"; + const compactIcon = icon && ["end", "start"].includes(iconPosition); + + return ( + + ); + })} + + +
    )}
    -
    - - {tabOptions.map((option) => { - const icon = getIconByName(option.icon, { fontSize: "small" }); - const iconPosition = option.iconPosition ?? "start"; - const compactIcon = icon && ["end", "start"].includes(iconPosition); - - return ( - - ); - })} - - -
    -
    - - {children} - -
    -
    -
    + > + {children} + +
    + + + {/* Not gated on isMobile: crossing the breakpoint with a dialog open — a rotate, or a + tablet at 900px — would unmount it mid-request, taking CippApiResults with it. + The hook already renders nothing until an action is dispatched. */} + {actionsDispatch.dialog} + {/* Actions only, and only when no page FAB claimed the corner — otherwise they ride in + that sheet. Tabs are in the title row and never come down here. */} + {isMobile && sheetActions.length > 0 && !tabNavValue.isActionCornerClaimed && ( + + )} + ); }; diff --git a/src/layouts/TabbedLayout.jsx b/src/layouts/TabbedLayout.jsx index af7403327d53..cc89fd11a794 100644 --- a/src/layouts/TabbedLayout.jsx +++ b/src/layouts/TabbedLayout.jsx @@ -1,10 +1,13 @@ -import { useMemo } from 'react' +import { useCallback, useMemo } from 'react' import { usePathname, useRouter } from 'next/navigation' import { Box, Divider, Stack, Tab, Tabs } from '@mui/material' import { useSearchParams } from 'next/navigation' import { ApiGetCall } from '../api/ApiCall' import { getIconByName } from '../utils/icon-registry' import { useSettings } from '../hooks/use-settings' +import { useIsMobileLayout } from '../hooks/use-breakpoint' +import { TabNavigationContext, useTabNavigationValue } from './tab-navigation-context' +import { CippTabPicker } from '../components/CippComponents/CippTabPicker' export const TabbedLayout = (props) => { const { tabOptions, children } = props @@ -37,57 +40,85 @@ export const TabbedLayout = (props) => { return tabs.filter((option) => !disabledPages.includes(option.path)) }, [tabOptions, featureFlags.isSuccess, featureFlags.data, showAdvanced]) - const handleTabsChange = (event, value) => { - // Preserve existing query parameters when changing tabs - const currentParams = new URLSearchParams(searchParams.toString()) - const queryString = currentParams.toString() - const newPath = queryString ? `${value}?${queryString}` : value - router.push(newPath) - } + const navigateToTab = useCallback( + (value) => { + // Preserve existing query parameters when changing tabs + const currentParams = new URLSearchParams(searchParams.toString()) + const queryString = currentParams.toString() + const newPath = queryString ? `${value}?${queryString}` : value + router.push(newPath) + }, + [router, searchParams] + ) + + const handleTabsChange = (event, value) => navigateToTab(value) const currentTab = visibleTabs.find((option) => option.path === pathname) + // Below md the tab row scrolls horizontally and still hides tabs off the right edge, so + // navigation collapses to a full-width picker in the slot the tab bar occupied. Always the + // layout's own row: a picker that sometimes annexes a heading somewhere on the page and + // sometimes doesn't is a control you have to go looking for. + const isMobile = useIsMobileLayout() + const tabNavValue = useTabNavigationValue({ + tabs: visibleTabs, + currentPath: pathname, + onNavigate: navigateToTab, + enabled: isMobile, + }) + return ( - - - - - {visibleTabs.map((option) => { - const icon = getIconByName(option.icon, { fontSize: 'small' }) - const iconPosition = option.iconPosition ?? 'start' - const compactIcon = icon && ['end', 'start'].includes(iconPosition) + + + + {isMobile && ( + // pt: 2 nets to the same 16px the sides and the Stack gap below pay: the + // breadcrumb divider's mb (8) is cancelled by this layout's mt: -1. + + + + )} + {!isMobile && ( + + + {visibleTabs.map((option) => { + const icon = getIconByName(option.icon, { fontSize: 'small' }) + const iconPosition = option.iconPosition ?? 'start' + const compactIcon = icon && ['end', 'start'].includes(iconPosition) - return ( - - ) - })} - - - - {children} - - + return ( + + ) + })} + + + + )} + {children} + + + ) } diff --git a/src/layouts/account-popover.js b/src/layouts/account-popover.js index 444ee7dcd4ac..e27a3aba6e83 100644 --- a/src/layouts/account-popover.js +++ b/src/layouts/account-popover.js @@ -2,8 +2,10 @@ import { useCallback } from "react"; import PropTypes from "prop-types"; import { useRouter } from "next/navigation"; import toast from "react-hot-toast"; +import ArrowPathIcon from "@heroicons/react/24/outline/ArrowPathIcon"; import ArrowRightOnRectangleIcon from "@heroicons/react/24/outline/ArrowRightOnRectangleIcon"; import ChevronDownIcon from "@heroicons/react/24/outline/ChevronDownIcon"; +import MagnifyingGlassIcon from "@heroicons/react/24/outline/MagnifyingGlassIcon"; import MoonIcon from "@heroicons/react/24/outline/MoonIcon"; import SunIcon from "@heroicons/react/24/outline/SunIcon"; import { @@ -22,22 +24,32 @@ import { useMediaQuery, } from "@mui/material"; import { usePopover } from "../hooks/use-popover"; +import { useIsMobileLayout } from "../hooks/use-breakpoint"; +import { useDialog } from "../hooks/use-dialog"; import { paths } from "../paths"; import { ApiGetCall } from "../api/ApiCall"; -import { CogIcon, DocumentTextIcon } from "@heroicons/react/24/outline"; +import { CippApiDialog } from "../components/CippComponents/CippApiDialog"; +import { CogIcon, DocumentTextIcon, LifebuoyIcon, TrashIcon } from "@heroicons/react/24/outline"; +import ArrowTopRightOnSquareIcon from "@heroicons/react/24/outline/ArrowTopRightOnSquareIcon"; import { useReleaseNotes } from "../contexts/release-notes-context"; import { useQueryClient } from "@tanstack/react-query"; +import { usePathname } from "next/navigation"; +import { Divider } from "@mui/material"; +import { getHelpLinks, clearCippCache } from "../utils/help-links"; export const AccountPopover = (props) => { const { direction = "ltr", language = "en", onThemeSwitch, + onOpenSearch, paletteMode = "light", ...other } = props; const router = useRouter(); + const pathname = usePathname(); const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md")); + const navCollapsed = useIsMobileLayout(); const popover = usePopover(); const queryClient = useQueryClient(); const { openReleaseNotes } = useReleaseNotes(); @@ -59,6 +71,11 @@ export const AccountPopover = (props) => { convertToDataUrl: true, }); + // Re-checks Entra group membership server-side, then refetches /api/me so a role granted + // through a just-activated PIM group applies without waiting out the role cache. Runs + // through the standard confirm dialog, which also renders the API result. + const refreshAccessDialog = useDialog(); + const handleLogout = useCallback(async () => { try { popover.handleClose(); @@ -125,6 +142,20 @@ export const AccountPopover = (props) => { )} + {orgData.data?.clientPrincipal?.userDetails && ( + + )} {orgData.data?.clientPrincipal?.userDetails && ( { PaperProps={{ sx: { width: 260 } }} > + {/* Pairs with the trigger above: the identity is either beside the avatar or here. */} {mdDown && ( + + + + )} + {/* Home for the two bar icons top-nav drops at navCollapsed (useIsMobileLayout), + so they stay reachable wherever the bar isn't showing them. */} + {navCollapsed && ( <> - - - + {onOpenSearch && ( + { + popover.handleClose(); + onOpenSearch(); + }} + > + + + + + + + + )} { popover.handleClose(); onThemeSwitch(); }}> @@ -177,6 +228,62 @@ export const AccountPopover = (props) => { + {/* Mobile home for the help SpeedDial's destinations — its FAB corner belongs + to page actions there (the SpeedDial hides itself below md). */} + {mdDown && ( + <> + + {getHelpLinks(pathname ?? "").map((link) => ( + { + popover.handleClose(); + window.open(link.href, "_blank"); + }} + > + + + + + + + + + + + ))} + { + popover.handleClose(); + clearCippCache(queryClient); + }} + > + + + + + + + + + + )} + { + popover.handleClose(); + refreshAccessDialog.handleOpen(); + }} + > + + + + + + + diff --git a/src/layouts/config.js b/src/layouts/config.js index 3be081ae159a..8dab949ae483 100644 --- a/src/layouts/config.js +++ b/src/layouts/config.js @@ -44,6 +44,11 @@ export const nativeMenuItems = [ path: '/identity/administration/users', permissions: ['Identity.User.*'], }, + { + title: 'Guest Users', + path: '/identity/administration/guest-users', + permissions: ['Identity.User.*'], + }, { title: 'Risky Users', path: '/identity/administration/risky-users', @@ -215,6 +220,7 @@ export const nativeMenuItems = [ title: 'Standards & Drift', permissions: [ 'Tenant.Standards.*', + 'Tenant.Baselines.*', 'Tenant.BestPracticeAnalyser.*', 'Tenant.DomainAnalyser.*', ], @@ -225,12 +231,12 @@ export const nativeMenuItems = [ permissions: ['Tenant.Standards.*'], scope: 'global', }, - // Baselines mockup - hidden from the nav for now; reach it directly + // Baselines - hidden from the nav for now; reach it directly // at /tenant/baselines // { // title: 'Baselines (Preview)', // path: '/tenant/baselines', - // permissions: ['Tenant.Standards.*'], + // permissions: ['Tenant.Baselines.*'], // scope: 'global', // }, { @@ -1086,6 +1092,11 @@ export const nativeMenuItems = [ path: '/email/tools/mailbox-restores', permissions: ['Exchange.Mailbox.*'], }, + { + title: 'Message Encryption', + path: '/email/tools/message-encryption', + permissions: ['Exchange.Mailbox.*'], + }, ], }, { diff --git a/src/layouts/constants.js b/src/layouts/constants.js index ee450b86e3dd..37c513ad06cc 100644 --- a/src/layouts/constants.js +++ b/src/layouts/constants.js @@ -7,8 +7,7 @@ export const TOP_NAV_HEIGHT = 64 export const SIDE_NAV_WIDTH = 290 -export const SIDE_NAV_PINNED_WIDTH = 50 -export const SIDE_NAV_COLLAPSED_WIDTH = 73 // icon size + padding + border right +export const SIDE_NAV_COLLAPSED_WIDTH = 73 // icon size + padding + border right; also the unpinned content offset // Height of the hosted maintenance banner, published by CippMaintenanceBanner via a CSS custom // property on :root so the fixed chrome can offset itself without prop threading. Resolves to 0px diff --git a/src/layouts/index.js b/src/layouts/index.js index 510a2c62a4aa..44c33201f056 100644 --- a/src/layouts/index.js +++ b/src/layouts/index.js @@ -1,7 +1,8 @@ import { useCallback, useEffect, useMemo, useState, useRef } from 'react' import { usePathname } from 'next/navigation' -import { Box, Container, Divider, Stack, useMediaQuery } from '@mui/material' +import { Box, Container, Divider, Stack } from '@mui/material' import { styled } from '@mui/material/styles' +import { useIsMobileLayout } from '../hooks/use-breakpoint' import { useSettings } from '../hooks/use-settings' import { Footer } from './footer' import { MobileNav } from './mobile-nav' @@ -19,10 +20,11 @@ import { ForcedSsoMigrationDialog } from '../components/CippComponents/ForcedSso import { SubscriptionEndedDialog } from '../components/CippComponents/SubscriptionEndedDialog' import { FailedPaymentDialog } from '../components/CippComponents/FailedPaymentDialog' import { CippMaintenanceBanner } from '../components/CippComponents/CippMaintenanceBanner' +import { CippImpersonationBanner } from '../components/CippComponents/CippImpersonationBanner' import { BANNER_HEIGHT_VAR, - SIDE_NAV_PINNED_WIDTH, + SIDE_NAV_COLLAPSED_WIDTH, SIDE_NAV_WIDTH, TOP_NAV_HEIGHT, } from './constants' @@ -56,6 +58,9 @@ const useMobileNav = () => { } } +// No breakpoint paddingLeft here: the side-nav offset is applied once, via the inline +// `sx` on the rendered LayoutRoot (it depends on pinNav). A second static rule at lg+ +// used to fight that dynamic one over the same property. const LayoutRoot = styled('div')(({ theme }) => ({ backgroundColor: theme.palette.background.default, display: 'flex', @@ -64,9 +69,6 @@ const LayoutRoot = styled('div')(({ theme }) => ({ height: '100vh', overflow: 'hidden', paddingTop: `calc(${TOP_NAV_HEIGHT}px + ${BANNER_HEIGHT_VAR})`, - [theme.breakpoints.up('lg')]: { - paddingLeft: SIDE_NAV_WIDTH, - }, })) const LayoutContainer = styled('div')({ @@ -82,7 +84,8 @@ export const Layout = (props) => { // showBreadcrumb: the error routes opt out — there is no trail to a page that // doesn't exist or just crashed, and the bookmark button lives in there too. const { children, allTenantsSupport = true, showBreadcrumb = true } = props - const mdDown = useMediaQuery((theme) => theme.breakpoints.down('md')) + // one gate for the swap: drawer, the hamburger that opens it (top-nav), the gutter below + const navCollapsed = useIsMobileLayout() const settings = useSettings() const mobileNav = useMobileNav() const [fetchingVisible, setFetchingVisible] = useState([]) @@ -203,7 +206,9 @@ export const Layout = (props) => { }) }, [settings]) - const offset = settings.pinNav ? SIDE_NAV_WIDTH : SIDE_NAV_PINNED_WIDTH + // Unpinned content offset must match the collapsed drawer's real width — the old 50px + // constant left 23px of content underneath the 73px rail. + const offset = settings.pinNav ? SIDE_NAV_WIDTH : SIDE_NAV_COLLAPSED_WIDTH const userSettingsAPI = ApiGetCall({ url: '/api/ListUserSettings', @@ -303,19 +308,26 @@ export const Layout = (props) => { <> {/* Rendered outside the hideSidebar check - maintenance applies to chrome-less pages too. */} + {hideSidebar === false && ( <> - {mdDown && ( - + {navCollapsed && ( + )} - {!mdDown && } + {!navCollapsed && } )} @@ -331,7 +343,7 @@ export const Layout = (props) => { - + { ) : ( - {showBreadcrumb && ( - <> - - - - - - )} + {/* The nav carries its own rail chrome (gutter + divider) so that when it + renders nothing — a single crumb on a phone — no hairline is left behind. */} + {showBreadcrumb && } {children} )} diff --git a/src/layouts/mobile-nav-item.js b/src/layouts/mobile-nav-item.js index 961d01ee33be..4367198a7ad7 100644 --- a/src/layouts/mobile-nav-item.js +++ b/src/layouts/mobile-nav-item.js @@ -24,6 +24,9 @@ export const MobileNavItem = (props) => { const isGlobal = scope === "global"; const [open, setOpen] = useState(openImmediately); + // same step as side-nav-item, nesting reads the same in both navs + const indent = depth > 0 ? depth * 1.5 : 1; + const handleToggle = useCallback(() => { setOpen((prevOpen) => !prevOpen); }, []); @@ -43,7 +46,7 @@ export const MobileNavItem = (props) => { fontSize: 14, fontWeight: 500, justifyContent: 'flex-start', - px: '6px', + px: `${indent * 6}px`, py: '12px', textAlign: 'left', whiteSpace: 'nowrap', @@ -119,7 +122,7 @@ export const MobileNavItem = (props) => { fontSize: 14, fontWeight: 500, justifyContent: 'flex-start', - px: '6px', + px: `${indent * 6}px`, py: '12px', textAlign: 'left', whiteSpace: 'nowrap', diff --git a/src/layouts/mobile-nav.js b/src/layouts/mobile-nav.js index 334914c822db..264b7cd97afe 100644 --- a/src/layouts/mobile-nav.js +++ b/src/layouts/mobile-nav.js @@ -1,18 +1,23 @@ +import { useMemo, useState } from "react"; import NextLink from "next/link"; import { usePathname } from "next/navigation"; import PropTypes from "prop-types"; -import { Box, Divider, Drawer, Stack } from "@mui/material"; +import { Box, Divider, InputAdornment, OutlinedInput, Stack, SwipeableDrawer, Typography } from "@mui/material"; +import { Search } from "@mui/icons-material"; import { Logo } from "../components/logo"; +import { CippSponsor } from "../components/CippComponents/CippSponsor"; import { Scrollbar } from "../components/scrollbar"; import { paths } from "../paths"; import { MobileNavItem } from "./mobile-nav-item"; import { SideNavBookmarks } from "./side-nav-bookmarks"; -import { CippTenantSelector } from "../components/CippComponents/CippTenantSelector"; import { useSettings } from "../hooks/use-settings"; +import { useSwipeCloseTransition } from "../hooks/use-swipe-close-transition"; -const MOBILE_NAV_WIDTH = "80%"; +// 80% of the viewport truncated third-level labels at 320px (256px) and was absurd at +// 899px (719px). Cap it like a real nav drawer. +const MOBILE_NAV_WIDTH = "min(360px, 88vw)"; -const renderItems = ({ depth = 0, items, pathname }) => +const renderItems = ({ depth = 0, items, pathname, forceOpen = false }) => items.reduce( (acc, item) => reduceChildRoutes({ @@ -20,11 +25,12 @@ const renderItems = ({ depth = 0, items, pathname }) => depth, item, pathname, + forceOpen, }), [] ); -const reduceChildRoutes = ({ acc, depth, item, pathname }) => { +const reduceChildRoutes = ({ acc, depth, item, pathname, forceOpen }) => { const checkPath = !!(item.path && pathname); // Special handling for root path "/" to avoid matching all paths const partialMatch = checkPath && item.path !== "/" ? pathname.includes(item.path) : false; @@ -37,8 +43,9 @@ const reduceChildRoutes = ({ acc, depth, item, pathname }) => { depth={depth} external={item.external} icon={item.icon} - key={item.title} - openImmediately={partialMatch} + // Search results re-render with a different key so collapse state resets open + key={`${item.title}-${forceOpen ? "open" : "closed"}`} + openImmediately={forceOpen || partialMatch} path={item.path} scope={item.scope} title={item.title} @@ -56,6 +63,7 @@ const reduceChildRoutes = ({ acc, depth, item, pathname }) => { depth: depth + 1, items: item.items, pathname, + forceOpen, })} @@ -78,60 +86,120 @@ const reduceChildRoutes = ({ acc, depth, item, pathname }) => { return acc; }; +// Prune the nav tree to items whose title matches the query, keeping ancestors of matches. +// A matching branch keeps its whole subtree so its children stay reachable. +const filterNavItems = (items, query) => + items.reduce((acc, item) => { + const selfMatch = item.title?.toLowerCase().includes(query); + if (item.items) { + if (selfMatch) { + acc.push(item); + return acc; + } + const filteredChildren = filterNavItems(item.items, query); + if (filteredChildren.length > 0) { + acc.push({ ...item, items: filteredChildren }); + } + return acc; + } + if (selfMatch) { + acc.push(item); + } + return acc; + }, []); + export const MobileNav = (props) => { - const { open, onClose, items } = props; + const { open, onClose, onOpen, items } = props; const pathname = usePathname(); const settings = useSettings(); + const swipeClose = useSwipeCloseTransition(open, onClose); + const [search, setSearch] = useState(""); const showSidebarBookmarks = settings.bookmarkSidebar !== false; + const query = search.trim().toLowerCase(); + const visibleItems = useMemo( + () => (query ? filterNavItems(items ?? [], query) : (items ?? [])), + [items, query] + ); + return ( - {})} open={open} + slotProps={{ transition: swipeClose.transitionProps }} PaperProps={{ sx: { + // desktop side-nav renders on background.default, keep the drawer on the same surface + backgroundColor: "background.default", width: MOBILE_NAV_WIDTH, + // Column layout so the sponsor footer pins to the bottom and the menu scrolls + // between it and the sticky header, rather than the footer riding the list. + display: "flex", + flexDirection: "column", }, }} variant="temporary" > + {/* Sticky header: logo (relocated from the mobile top bar) + nav search */} + + + + + setSearch(event.target.value)} + inputProps={{ enterKeyHint: "search", "aria-label": "Search navigation" }} + startAdornment={ + + + + } + sx={{ minHeight: 44 }} + /> + - - - - - - - - { }} > {/* Bookmarks section above Dashboard */} - {showSidebarBookmarks && ( + {showSidebarBookmarks && !query && ( <> @@ -153,17 +221,36 @@ export const MobileNav = (props) => { {/* Render all menu items */} {renderItems({ depth: 0, - items, + items: visibleItems, pathname, + forceOpen: Boolean(query), })} + {query && visibleItems.length === 0 && ( + + No pages match “{search}”. + + )} - + {/* Pinned below the scrolling menu rather than at the end of it, so it stays visible + without the long nav list pushing it off-screen. Compact: the drawer's vertical + space belongs to navigation. */} + + + + ); }; MobileNav.propTypes = { onClose: PropTypes.func, + onOpen: PropTypes.func, open: PropTypes.bool, }; diff --git a/src/layouts/notifications-popover.js b/src/layouts/notifications-popover.js index dddbf442555d..b585b6871d5e 100644 --- a/src/layouts/notifications-popover.js +++ b/src/layouts/notifications-popover.js @@ -88,7 +88,22 @@ export const NotificationsPopover = () => { return ( <> - + diff --git a/src/layouts/side-nav-bookmarks.js b/src/layouts/side-nav-bookmarks.js index 0ae0ec7abdec..08bba90d2b84 100644 --- a/src/layouts/side-nav-bookmarks.js +++ b/src/layouts/side-nav-bookmarks.js @@ -16,10 +16,15 @@ import ChevronDownIcon from "@heroicons/react/24/outline/ChevronDownIcon"; import { useSettings } from "../hooks/use-settings"; import { useUserBookmarks } from "../hooks/use-user-bookmarks"; -export const SideNavBookmarks = ({ collapse = false }) => { +// alignWithRail: the pinned side nav sits beside the content area's breadcrumb rail, and the +// two header rows share a divider line across the seam — the desktop nav passes this so the +// Bookmarks row matches the rail's 28px row instead of the 48px nav-item rhythm. The mobile +// drawer has no rail beside it and keeps the roomier row. +export const SideNavBookmarks = ({ collapse = false, alignWithRail = false }) => { const settings = useSettings(); const compactNav = settings.compactNav ?? false; const navItemPy = compactNav ? "6px" : "12px"; + const headerPy = alignWithRail ? "2px" : navItemPy; const emptyStatePy = compactNav ? "4px" : "8px"; const { bookmarks, setBookmarks } = useUserBookmarks(); const [open, setOpen] = useState(settings.bookmarksOpen ?? false); @@ -190,7 +195,7 @@ export const SideNavBookmarks = ({ collapse = false }) => { fontWeight: 500, justifyContent: "flex-start", px: "6px", - py: navItemPy, + py: headerPy, textAlign: "left", whiteSpace: "nowrap", width: "100%", diff --git a/src/layouts/side-nav.js b/src/layouts/side-nav.js index cd8cd5ce834d..5181643c64e8 100644 --- a/src/layouts/side-nav.js +++ b/src/layouts/side-nav.js @@ -227,6 +227,9 @@ export const SideNav = (props) => { flexDirection: 'column', height: '100%', p: 2, + // The breadcrumb rail across the seam starts 10px under the top nav; starting + // the Bookmarks header at the same offset lets the two rows share a line. + pt: '10px', }} > { {/* Bookmarks section above Dashboard */} {showSidebarBookmarks && ( <> - - + + {/* mt matches the rail row's mb: 1, so the dividers meet across the seam */} + )} {/* Render all menu items */} diff --git a/src/layouts/tab-navigation-context.js b/src/layouts/tab-navigation-context.js new file mode 100644 index 000000000000..69b175921e4c --- /dev/null +++ b/src/layouts/tab-navigation-context.js @@ -0,0 +1,116 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useId, + useMemo, + useState, +} from 'react' +import { useIsMobileLayout } from '../hooks/use-breakpoint' + +/** + * Lets a tabbed layout publish its tab list — and, on the headered variant, its page actions. + * + * Below md the scrollable tab row costs a band of vertical space and still hides tabs off the + * right edge, so navigation collapses to a picker in the content flow (CippTabPicker). That + * picker is always drawn by the layout, so there is nothing to negotiate over it. + * + * The FAB corner is different: it fits exactly one FAB, and about a quarter of tabbed pages + * already grow one from a table's `cardButton`. A headered layout therefore hands its actions + * to that FAB rather than adding a second one — hence the claim registry below. + */ +export const TabNavigationContext = createContext(null) + +export const useTabNavigation = () => useContext(TabNavigationContext) + +/** + * Claims the bottom-right corner while `active`. A claimant takes responsibility for making + * the layout's actions reachable — or for deliberately withholding them, as the card list does + * while its select-mode bulk bar owns the bottom of the screen. + */ +export const useActionCornerClaim = (active) => { + const context = useContext(TabNavigationContext) + const claimId = useId() + const claim = context?.claim + const release = context?.release + + useEffect(() => { + if (!active || !claim || !release) return undefined + claim(claimId) + return () => release(claimId) + }, [active, claim, release, claimId]) +} + +/** + * True when the mobile tab picker already names this page. The picker trigger wears the + * current tab's label in heading clothes directly above the page header, so a page whose own + * title is the same string would print it twice in a row. The page keeps its title on + * desktop, where the tab bar looks like navigation rather than a heading. + */ +export const useTitleClaimedByTabPicker = (title) => { + const context = useContext(TabNavigationContext) + const isMobile = useIsMobileLayout() + // Mirrors CippTabPicker's own render conditions: below two destinations it draws nothing, + // so there is no trigger to claim the title. + if (!isMobile || !context?.enabled || (context.tabs?.length ?? 0) < 2 || !title) return false + const current = context.tabs.find((tab) => tab.path === context.currentPath) + return current?.label?.trim().toLowerCase() === String(title).trim().toLowerCase() +} + +/** + * Builds the context value for a tabbed layout. `tabs` are the already-filtered options + * ({label, path, icon}); `onNavigate` receives a path. + */ +export const useTabNavigationValue = ({ + tabs, + currentPath, + onNavigate, + actions = [], + enabled, + // HeaderedTabbedLayout wraps its children in a Container; TabbedLayout does not. Content + // that renders its own Container (CippFormPage) reads this so the two don't double up. + providesGutters = false, +}) => { + const [claims, setClaims] = useState([]) + + // An aliased route (pages/index.js re-exports the dashboard, so it renders at "/") matches + // no tab path — which left the picker labelled "Views" with nothing checked. The page an + // alias re-exports is one of these tabs, and in practice the first: treat it as current. + const resolvedPath = tabs?.some((tab) => tab.path === currentPath) + ? currentPath + : (tabs?.[0]?.path ?? currentPath) + + const claim = useCallback((id) => { + setClaims((prev) => (prev.includes(id) ? prev : [...prev, id])) + }, []) + + const release = useCallback((id) => { + setClaims((prev) => prev.filter((claimId) => claimId !== id)) + }, []) + + return useMemo( + () => ({ + enabled, + tabs, + currentPath: resolvedPath, + onNavigate, + actions, + providesGutters, + claim, + release, + isActionCornerClaimed: claims.length > 0, + }), + [ + enabled, + tabs, + resolvedPath, + onNavigate, + actions, + providesGutters, + claim, + release, + claims.length, + ] + ) +} diff --git a/src/layouts/top-nav.js b/src/layouts/top-nav.js index f3de8a1920e7..f796bc222996 100644 --- a/src/layouts/top-nav.js +++ b/src/layouts/top-nav.js @@ -26,7 +26,6 @@ import { Stack, SvgIcon, Tooltip, - useMediaQuery, Popover, List, ListItem, @@ -35,11 +34,13 @@ import { } from '@mui/material' import { useTheme } from '@mui/material/styles' import { Logo } from '../components/logo' +import { useIsMobileLayout } from '../hooks/use-breakpoint' import { useSettings } from '../hooks/use-settings' import { useUserBookmarks } from '../hooks/use-user-bookmarks' import { paths } from '../paths' import { AccountPopover } from './account-popover' import { CippTenantSelector } from '../components/CippComponents/CippTenantSelector' +import { CippMobileTenantPicker } from '../components/CippComponents/CippMobileTenantPicker' import { NotificationsPopover } from './notifications-popover' import { useDialog } from '../hooks/use-dialog' import { CippUniversalSearchV2 } from '../components/CippCards/CippUniversalSearchV2' @@ -53,7 +54,8 @@ export const TopNav = (props) => { const { onNavOpen } = props const settings = useSettings() const { bookmarks, setBookmarks } = useUserBookmarks() - const mdDown = useMediaQuery((theme) => theme.breakpoints.down('md')) + // same gate as the side nav in layouts/index.js, the hamburger below is the drawer's only opener + const navCollapsed = useIsMobileLayout() const showPopoverBookmarks = settings.bookmarkPopover === true const reorderMode = settings.bookmarkReorderMode || 'arrows' const locked = settings.bookmarkLocked ?? true @@ -263,35 +265,44 @@ export const TopNav = (props) => { alignItems="center" sx={{ minHeight: TOP_NAV_HEIGHT, - px: 3, + // Mobile: the 24px desktop inset pushed the hamburger far off the left edge — + // an 8px inset puts the ☰ glyph on the content gutter line. + px: { xs: 1, md: 3 }, }} > + navCollapsed ? undefined : ( + + ) } > - - - - {!mdDown && ( + {/* On phones the logo gives way to the tenant chip — the app's primary scoping + control earns the space a 24px decorative link was using. */} + {!navCollapsed && ( + + + + )} + {!navCollapsed && ( { /> )} - {mdDown && ( - + {navCollapsed && ( + )} + {navCollapsed && ( + + + + )} - - {!mdDown && ( + {/* 0.5 left the notification dot and the account avatar sharing the same few pixels */} + + {!navCollapsed && ( { )} - {!mdDown && ( + {!navCollapsed && ( {effectivePaletteMode === 'dark' ? : } )} - {!mdDown && ( + {!navCollapsed && ( { )} + {/* Mobile: no search icon in the bar — the tenant chip is the more important + control and gets the width. Universal search lives in the account menu. */} {showPopoverBookmarks && ( <> @@ -627,17 +651,18 @@ export const TopNav = (props) => { open={universalSearchDialog.open} onClose={closeUniversalSearch} fullWidth + fullScreen={navCollapsed} maxWidth="md" sx={{ '& .MuiDialog-container': { alignItems: 'flex-start', }, '& .MuiDialog-paper': { - mt: 8, + mt: navCollapsed ? 0 : 8, }, }} > - + { useFlexGap spacing={1} > - Universal Search - - Pages: Ctrl/Cmd+K · Users: Ctrl/Cmd+Shift+F · Tenant: Ctrl/Cmd+Alt+K - + + {/* Fullscreen on mobile leaves no backdrop to tap — provide a close button */} + {navCollapsed && ( + + + + )} + Universal Search + + {!navCollapsed && ( + + Pages: Ctrl/Cmd+K · Users: Ctrl/Cmd+Shift+F · Tenant: Ctrl/Cmd+Alt+K + + )} @@ -680,6 +719,7 @@ export const TopNav = (props) => { openUniversalSearch('Pages')} paletteMode={effectivePaletteMode === 'light' ? 'dark' : 'light'} /> diff --git a/src/pages/_app.js b/src/pages/_app.js index c0d6d5e0daeb..a3f69ebe2727 100644 --- a/src/pages/_app.js +++ b/src/pages/_app.js @@ -53,9 +53,12 @@ import { AutoStories, Gavel, ClearAll as ClearAllIcon, + SupportAgent, + FiberManualRecord, } from '@mui/icons-material' import { School as TutorialIcon } from '@mui/icons-material' -import { SvgIcon } from '@mui/material' +import { getHelpLinks, clearCippCache } from '../utils/help-links' +import { Chip, SvgIcon } from '@mui/material' import React, { useEffect, useState, useRef } from 'react' import { usePathname } from 'next/navigation' import { useRouter } from 'next/router' @@ -63,6 +66,7 @@ import { persistQueryClient } from '@tanstack/react-query-persist-client' import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister' import { TutorialProvider } from '../contexts/tutorial-context' import CippTutorialDialog from '../components/CippComponents/CippTutorialDialog' +import CippSupportBundleDialog from '../components/CippComponents/CippSupportBundleDialog' const ReactQueryDevtoolsProduction = React.lazy(() => import('@tanstack/react-query-devtools/build/modern/production.js').then((d) => ({ @@ -91,6 +95,8 @@ const App = (props) => { const route = useRouter() const [dateLocale, setDateLocale] = useState(enUS) const [tutorialDialogOpen, setTutorialDialogOpen] = useState(false) + const [supportBundleOpen, setSupportBundleOpen] = useState(false) + const [supportRecording, setSupportRecording] = useState(false) useEffect(() => { if (typeof window === 'undefined') return @@ -195,29 +201,21 @@ const App = (props) => { } }, []) + // Link/cache destinations are shared with AccountPopover's mobile help section — see + // utils/help-links.js. Only the icons and SpeedDial-specific actions live here. + const helpLinkIcons = { + 'bug-report': , + 'feature-request': , + discord: Discord, + documentation: , + } + const speedDialActions = [ { - // add clear cache action that removes the persisted query cache from local storage and reloads the page id: 'clearCache', icon: , name: 'Clear Cache and Reload', - onClick: () => { - // Clear the TanStack Query cache - queryClient.clear() - - // Remove persisted cache from localStorage - if (typeof window !== 'undefined') { - // Remove the persisted query cache keys - Object.keys(localStorage).forEach((key) => { - if (key.startsWith('REACT_QUERY_OFFLINE_CACHE')) { - localStorage.removeItem(key) - } - }) - } - - // Force refresh the page to bypass browser cache and reload JavaScript - window.location.reload(true) - }, + onClick: () => clearCippCache(queryClient), }, { id: 'license', @@ -227,38 +225,16 @@ const App = (props) => { onClick: () => route.push('/license'), }, { - id: 'bug-report', - icon: , - name: 'Report Bug', - href: 'https://github.com/CyberDrain/CIPP/issues/new?template=bug.yml', - onClick: () => - window.open('https://github.com/CyberDrain/CIPP/issues/new?template=bug.yml', '_blank'), - }, - { - id: 'feature-request', - icon: , - name: 'Request Feature', - href: 'https://github.com/CyberDrain/CIPP/issues/new?template=feature.yml', - onClick: () => - window.open( - 'https://github.com/CyberDrain/CIPP/issues/new?template=feature.yml', - '_blank' - ), - }, - { - id: 'discord', - icon: Discord, - name: 'Join the Discord!', - href: 'https://discord.gg/cyberdrain', - onClick: () => window.open('https://discord.gg/cyberdrain', '_blank'), - }, - { - id: 'documentation', - icon: , - name: 'Check the Documentation', - href: `https://docs.cipp.app/user-documentation${pathname}`, - onClick: () => window.open(`https://docs.cipp.app/user-documentation${pathname}`, '_blank'), + id: 'supportBundle', + icon: , + name: 'Generate Support File', + onClick: () => setSupportBundleOpen(true), }, + ...getHelpLinks(pathname).map((link) => ({ + ...link, + icon: helpLinkIcons[link.id], + onClick: () => window.open(link.href, '_blank'), + })), { id: 'tutorials', icon: , @@ -305,10 +281,34 @@ const App = (props) => { open={tutorialDialogOpen} onClose={() => setTutorialDialogOpen(false)} /> + setSupportBundleOpen(false)} + onRecordingChange={setSupportRecording} + /> + {supportRecording && !supportBundleOpen && ( + } + label="Recording — click to stop" + color="error" + onClick={() => setSupportBundleOpen(true)} + sx={{ + position: 'fixed', + bottom: 20, + // Pinned left of the speed dial FAB (46px wide + 12px gap), + // which itself shifts left when devtools is enabled. + right: + (settings.isInitialized && settings?.showDevtools === true + ? 60 + : 12) + 58, + zIndex: (muiTheme) => muiTheme.zIndex.speedDial, + }} + /> + )} } diff --git a/src/pages/cipp/advanced/authentication/cipp-roles/index.js b/src/pages/cipp/advanced/authentication/cipp-roles/index.js index b759ddfa29b6..fc2c7e20aab4 100644 --- a/src/pages/cipp/advanced/authentication/cipp-roles/index.js +++ b/src/pages/cipp/advanced/authentication/cipp-roles/index.js @@ -3,19 +3,20 @@ import { Layout as DashboardLayout } from "../../../../../layouts/index.js"; import tabOptions from "../tabOptions"; import CippPageCard from "../../../../../components/CippCards/CippPageCard"; import CippRoles from "../../../../../components/CippSettings/CippRoles"; -import { CardContent, Stack, Alert } from "@mui/material"; +import { CippExpandableAlert } from "../../../../../components/CippComponents/CippExpandableAlert"; +import { CardContent, Stack } from "@mui/material"; const Page = () => { return ( - + Custom roles can be used to restrict permissions for users with the 'editor' or 'readonly' roles in CIPP. They can be limited to a subset of tenants and API permissions. Built-in and custom roles can be assigned to Entra security groups for granular access control. - + diff --git a/src/pages/cipp/advanced/authentication/cipp-users.js b/src/pages/cipp/advanced/authentication/cipp-users.js index ad3b6097c147..c2d37ea3f344 100644 --- a/src/pages/cipp/advanced/authentication/cipp-users.js +++ b/src/pages/cipp/advanced/authentication/cipp-users.js @@ -3,14 +3,17 @@ import { Layout as DashboardLayout } from "../../../../layouts/index.js"; import tabOptions from "./tabOptions"; import CippPageCard from "../../../../components/CippCards/CippPageCard"; import { CippUserManagement } from "../../../../components/CippSettings/CippUserManagement"; -import { CardContent, Stack, Alert } from "@mui/material"; +import { CippExpandableAlert } from "../../../../components/CippComponents/CippExpandableAlert"; +import { CardContent, Stack } from "@mui/material"; const Page = () => { return ( - + // Titled to match the tab label, so the mobile picker claims the heading and the page + // does not say "CIPP Users" and "CIPP User Management" back to back. + - + Manage users who can access CIPP. Users are automatically synced from your partner tenant every 15 minutes based on Entra group memberships configured on the CIPP Roles page. You can also manually add users or assign additional roles — manual assignments @@ -23,7 +26,7 @@ const Page = () => { to access CIPP, you can add them as guest users in your partner tenant and assign them the appropriate roles in CIPP or enable the multi tenant mode in the CIPP SSO tab and add the users to the list below without needing to add them as guest users in your tenant. - + diff --git a/src/pages/cipp/advanced/authentication/sso.js b/src/pages/cipp/advanced/authentication/sso.js index fc5b112f3f1c..ec8cb012df9a 100644 --- a/src/pages/cipp/advanced/authentication/sso.js +++ b/src/pages/cipp/advanced/authentication/sso.js @@ -7,7 +7,7 @@ import { CippSSOSettings } from "../../../../components/CippSettings/CippSSOSett const Page = () => { return ( - + diff --git a/src/pages/cipp/advanced/container-management/logs.js b/src/pages/cipp/advanced/container-management/logs.js index 9b5682001207..44f7745e5388 100644 --- a/src/pages/cipp/advanced/container-management/logs.js +++ b/src/pages/cipp/advanced/container-management/logs.js @@ -23,6 +23,7 @@ import { CippTablePage } from "../../../../components/CippComponents/CippTablePa import { ApiGetCall } from "../../../../api/ApiCall"; import defaultPresets from "../../../../data/ContainerLogPresets.json"; import tabOptions from "./tabOptions"; +import { CippExpandableAlert } from "../../../../components/CippComponents/CippExpandableAlert"; const levelOptions = [ { label: "All Levels", value: "" }, @@ -249,7 +250,7 @@ const ContainerLogsFilter = ({ onSubmitFilter }) => { {tabValue === 0 && ( - + Query Syntax Use a KQL-inspired pipe syntax to filter container logs. Separate clauses with{" "} @@ -279,7 +280,7 @@ const ContainerLogsFilter = ({ onSubmitFilter }) => {
    search all files — include rotated logs
    -
    + @@ -310,7 +311,7 @@ const ContainerLogsFilter = ({ onSubmitFilter }) => { }} /> - + diff --git a/src/pages/cipp/advanced/container-management/worker-health.js b/src/pages/cipp/advanced/container-management/worker-health.js index b1eb44776b4d..27a5b422cc75 100644 --- a/src/pages/cipp/advanced/container-management/worker-health.js +++ b/src/pages/cipp/advanced/container-management/worker-health.js @@ -64,6 +64,7 @@ import { CippInfoBar } from "../../../../components/CippCards/CippInfoBar"; import { CippDataTable } from "../../../../components/CippTable/CippDataTable"; import { ApiGetCall, ApiPostCall } from "../../../../api/ApiCall"; import tabOptions from "./tabOptions"; +import { useTitleClaimedByTabPicker } from "../../../../layouts/tab-navigation-context"; const formatDuration = (ms) => { if (ms === 0 || ms == null) return "—"; @@ -352,6 +353,8 @@ const CompactStatsRow = ({ snapshot }) => { { k: "Queued", v: jobs.Queued ?? 0, w: jobs.Queued > 10 }, { k: "Done", v: jobs.Completed?.toLocaleString() ?? 0 }, { k: "Failed", v: jobs.Failed ?? 0, w: jobs.Failed > 0 }, + // Stale queue entries whose task was gone by dispatch time — benign, so never flagged. + { k: "Skipped", v: jobs.Skipped ?? 0 }, ], }, { @@ -459,6 +462,7 @@ const HistoryChart = ({ data, rangeMinutes, title, icon, children }) => { const Page = () => { const theme = useTheme(); + const titleClaimed = useTitleClaimedByTabPicker("Worker Health"); const queryClient = useQueryClient(); const fileInputRef = useRef(null); const [historyRange, setHistoryRange] = useState(60); @@ -720,7 +724,9 @@ const Page = () => { {/* ── Header toolbar ── */} - Worker Health + {/* Empty Box keeps the toolbar on the right when the mobile tab picker has + already said "Worker Health" directly above this row. */} + {titleClaimed ? : Worker Health} {isImported && ( { }} simpleColumns={jobSimpleColumns} actions={jobActions} + offCanvas={{ + extendedInfoFields: [ + "Id", + "Name", + "RunName", + "Status", + "Priority", + "QueuedUtc", + "StartedUtc", + "CompletedUtc", + "WaitSeconds", + "DurationSeconds", + "LastError", + ], + }} defaultSorting={[{ id: "QueuedUtc", desc: true }]} cardButton={ @@ -846,7 +867,7 @@ const Page = () => { onChange={(_, val) => val !== null && setJobStatus(val)} size="small" > - {["", "Queued", "Running", "Completed", "Failed", "Cancelled"].map((s) => ( + {["", "Queued", "Running", "Completed", "Failed", "Cancelled", "Skipped"].map((s) => ( {s || "All"} @@ -1145,7 +1166,7 @@ const Page = () => { /> {/* Stats row */} - + {cacheStats.map((s) => { const cell = ( diff --git a/src/pages/cipp/advanced/super-admin/jit-admin-settings.js b/src/pages/cipp/advanced/super-admin/jit-admin-settings.js index fa6401e9b7c3..514b5b097c07 100644 --- a/src/pages/cipp/advanced/super-admin/jit-admin-settings.js +++ b/src/pages/cipp/advanced/super-admin/jit-admin-settings.js @@ -7,6 +7,7 @@ import { Typography, Alert } from "@mui/material"; import { Grid } from "@mui/system"; import CippFormComponent from "../../../../components/CippComponents/CippFormComponent"; import { ApiGetCall } from "../../../../api/ApiCall"; +import { CippExpandableAlert } from "../../../../components/CippComponents/CippExpandableAlert"; import { useEffect } from "react"; const Page = () => { @@ -103,7 +104,7 @@ const Page = () => { - + Important Notes: @@ -121,7 +122,7 @@ const Page = () => {
  • This setting applies globally to all tenants and all JIT admin creations
  • - + diff --git a/src/pages/cipp/advanced/table-maintenance.js b/src/pages/cipp/advanced/table-maintenance.js index fea95d6e3774..a6f83e4dfe96 100644 --- a/src/pages/cipp/advanced/table-maintenance.js +++ b/src/pages/cipp/advanced/table-maintenance.js @@ -66,8 +66,15 @@ const CustomAddEditRowDialog = ({ formControl, open, onClose, onSubmit, defaultV {Array.isArray(fields) && fields?.length > 0 && ( <> {fields.map((field, index) => ( - - + + - + - + { that should only be used when directed by CyberDrain support. - + { } /> - + {selectedTable && ( diff --git a/src/pages/cipp/custom-data/schema-extensions/index.js b/src/pages/cipp/custom-data/schema-extensions/index.js index fab26e70f51c..17faba908d40 100644 --- a/src/pages/cipp/custom-data/schema-extensions/index.js +++ b/src/pages/cipp/custom-data/schema-extensions/index.js @@ -6,6 +6,7 @@ import { Add, Block, CheckCircleOutline } from "@mui/icons-material"; import tabOptions from "../tabOptions"; import { TrashIcon } from "@heroicons/react/24/outline"; import NextLink from "next/link"; +import { CippExpandableAlert } from "../../../../components/CippComponents/CippExpandableAlert"; const Page = () => { const pageTitle = "Schema Extensions"; @@ -107,7 +108,7 @@ const Page = () => { + {
  • There is a limit of 5 total schema extensions.
  • - +
    } cardButton={ + } + > + + + + Microsoft Purview Message Encryption lets users send protected email + to any recipient, including Gmail and Outlook.com. The only + prerequisite is that Azure Rights Management is active for the + tenant. + + + {irmRequest.isError && ( + + + Failed to load the IRM configuration for this tenant. + + + )} + {irm?.AdRmsDetected && ( + + + This tenant has an on-premises AD RMS licensing location ({' '} + {irm.LicensingLocation.join(', ')} ). Purview Message Encryption + is not compatible with AD RMS, so the tenant has to be{' '} + + migrated to Azure RMS + {' '} + before enabling it. + + + )} + {(irm || irmRequest.isFetching) && ( + + + + )} + + + + + Test the configuration + + Runs Test-IRMConfiguration, which verifies that RMS templates can be + acquired and that encryption and decryption both work. Use any + mailbox in the tenant for both addresses. + + + + `${option.displayName} (${option.UPN})`, + valueField: 'UPN', + }} + /> + + + `${option.displayName} (${option.UPN})`, + valueField: 'UPN', + }} + /> + + + + + + + ) +} + +Page.getLayout = (page) => {page} + +export default Page diff --git a/src/pages/endpoint/MEM/assignment-filters/index.js b/src/pages/endpoint/MEM/assignment-filters/index.js index bedf0ef1ada4..e41259dad6a6 100644 --- a/src/pages/endpoint/MEM/assignment-filters/index.js +++ b/src/pages/endpoint/MEM/assignment-filters/index.js @@ -88,9 +88,9 @@ const Page = () => { - {reportDB.controls}
    } + dataSourceControls={reportDB.controls} apiUrl={reportDB.resolvedApiUrl} queryKey={reportDB.resolvedQueryKey} actions={actions} diff --git a/src/pages/endpoint/MEM/devices/device/index.jsx b/src/pages/endpoint/MEM/devices/device/index.jsx index 079bc4307d46..25fdbcff7b18 100644 --- a/src/pages/endpoint/MEM/devices/device/index.jsx +++ b/src/pages/endpoint/MEM/devices/device/index.jsx @@ -18,6 +18,7 @@ import { Group, } from '@mui/icons-material' import { HeaderedTabbedLayout } from '../../../../../layouts/HeaderedTabbedLayout' +import { CippEntitySwitcher } from '../../../../../components/CippComponents/CippEntitySwitcher' import tabOptions from './tabOptions' import { CippCopyToClipBoard } from '../../../../../components/CippComponents/CippCopyToClipboard' import { getIntuneDeviceActions } from '../../../../../components/CippComponents/CippIntuneDeviceActions.jsx' @@ -477,6 +478,28 @@ const Page = () => { device.deviceName} + getSecondary={(device) => device.userPrincipalName} + sortByPrimary + /> + } actions={deviceActions} actionsData={data} subtitle={subtitle} @@ -492,7 +515,7 @@ const Page = () => { > - + { - + Compliance Policies { requiredPermissions={cardButtonPermissions} PermissionButton={PermissionButton} /> - {reportDB.controls} } + dataSourceControls={reportDB.controls} /> {reportDB.syncDialog} diff --git a/src/pages/endpoint/MEM/list-compliance-policies/index.js b/src/pages/endpoint/MEM/list-compliance-policies/index.js index 32574567a0ff..f3f8299baa1f 100644 --- a/src/pages/endpoint/MEM/list-compliance-policies/index.js +++ b/src/pages/endpoint/MEM/list-compliance-policies/index.js @@ -65,9 +65,9 @@ const Page = () => { requiredPermissions={cardButtonPermissions} PermissionButton={PermissionButton} /> - {reportDB.controls}
    } + dataSourceControls={reportDB.controls} /> {reportDB.syncDialog} diff --git a/src/pages/endpoint/MEM/list-policies/index.js b/src/pages/endpoint/MEM/list-policies/index.js index 5895224b9e14..b20f3733ad22 100644 --- a/src/pages/endpoint/MEM/list-policies/index.js +++ b/src/pages/endpoint/MEM/list-policies/index.js @@ -69,9 +69,9 @@ const Page = () => { requiredPermissions={cardButtonPermissions} PermissionButton={PermissionButton} /> - {reportDB.controls}
    } + dataSourceControls={reportDB.controls} /> {reportDB.syncDialog} diff --git a/src/pages/endpoint/MEM/list-scripts/index.jsx b/src/pages/endpoint/MEM/list-scripts/index.jsx index 2661e040c2a4..0a1d6ead381d 100644 --- a/src/pages/endpoint/MEM/list-scripts/index.jsx +++ b/src/pages/endpoint/MEM/list-scripts/index.jsx @@ -502,7 +502,7 @@ const Page = () => { actions={actions} offCanvas={offCanvas} simpleColumns={simpleColumns} - cardButton={reportDB.controls} + dataSourceControls={reportDB.controls} /> diff --git a/src/pages/endpoint/MEM/reusable-settings/index.js b/src/pages/endpoint/MEM/reusable-settings/index.js index 75219f0d4136..b42086aa1afa 100644 --- a/src/pages/endpoint/MEM/reusable-settings/index.js +++ b/src/pages/endpoint/MEM/reusable-settings/index.js @@ -76,9 +76,9 @@ const Page = () => { cardButton={ - {reportDB.controls} } + dataSourceControls={reportDB.controls} apiUrl={reportDB.resolvedApiUrl} queryKey={reportDB.resolvedQueryKey} actions={actions} diff --git a/src/pages/endpoint/applications/list/index.js b/src/pages/endpoint/applications/list/index.js index 232e55a21dae..cdbbc0d97baa 100644 --- a/src/pages/endpoint/applications/list/index.js +++ b/src/pages/endpoint/applications/list/index.js @@ -386,9 +386,9 @@ const Page = () => { - {reportDB.controls} } + dataSourceControls={reportDB.controls} /> { group.mail} + /> + } actions={groupActions} actionsData={data} subtitle={subtitle} @@ -699,7 +721,7 @@ const Page = () => { > - + @@ -806,7 +828,7 @@ const Page = () => { - + Members { const pageTitle = 'Groups' - const [showMembers, setShowMembers] = useState(false) - const [showOwners, setShowOwners] = useState(false) const { currentTenant } = useSettings() + const tenantQuery = + currentTenant === 'AllTenants' ? '[Tenant]' : currentTenant + const nestedTenantQuery = + currentTenant === 'AllTenants' ? '[parent.Tenant]' : currentTenant const reportDB = useCippReportDB({ apiUrl: '/api/ListGroups', @@ -35,25 +39,10 @@ const Page = () => { cacheColumns: ['CacheTimestamp'], }) - const handleMembersToggle = () => { - setShowMembers((prev) => { - const next = !prev - if (next) setShowOwners(false) - return next - }) - } - - const handleOwnersToggle = () => { - setShowOwners((prev) => { - const next = !prev - if (next) setShowMembers(false) - return next - }) - } const actions = [ { label: 'View Group', - link: `/identity/administration/groups/group?groupId=[id]&tenantFilter=${currentTenant}`, + link: `/identity/administration/groups/group?groupId=[id]&tenantFilter=${tenantQuery}`, color: 'info', icon: , multiPost: false, @@ -66,6 +55,81 @@ const Page = () => { icon: , color: 'success', }, + { + label: 'Add Member', + type: 'POST', + url: '/api/EditGroup', + icon: , + customDataformatter: (row, action, formData) => { + // Members picked in the dialog already carry {label, value: id, addedFields} + const addMember = [...(formData.AddMember ?? [])] + // CSV rows only carry a userPrincipalName; without a value the backend + // resolves the directory object id itself + ;(formData.bulkMember ?? []).forEach((csvRow) => { + const upnKey = Object.keys(csvRow).find( + (key) => key.trim().toLowerCase() === 'userprincipalname' + ) + const userPrincipalName = upnKey ? csvRow[upnKey]?.trim() : undefined + if (userPrincipalName) { + addMember.push({ + label: userPrincipalName, + addedFields: { userPrincipalName: userPrincipalName }, + }) + } + }) + + // Handle multiple groups - return an array of requests (one per group) + const selectedGroups = Array.isArray(row) ? row : [row] + return selectedGroups.map((group) => ({ + AddMember: addMember, + tenantFilter: getRowTenant(group, currentTenant), + groupId: group.id, + groupName: group.displayName, + groupType: group.groupType, + })) + }, + fields: [ + { + type: 'autoComplete', + name: 'AddMember', + label: 'Select users to add as members', + multiple: true, + creatable: false, + api: { + url: '/api/ListGraphRequest', + data: { + Endpoint: 'users', + $select: 'id,displayName,userPrincipalName', + $top: 999, + $count: true, + }, + dataKey: 'Results', + labelField: (user) => `${user.displayName} (${user.userPrincipalName})`, + valueField: 'id', + addedField: { + userPrincipalName: 'userPrincipalName', + displayName: 'displayName', + }, + queryKey: 'ListUsersAutoComplete', + showRefresh: true, + }, + validators: { + validate: (value, formValues) => + (Array.isArray(value) && value.length > 0) || + (Array.isArray(formValues.bulkMember) && formValues.bulkMember.length > 0) || + 'Select at least one user or upload a CSV', + }, + }, + { + type: 'CSVReader', + name: 'bulkMember', + }, + ], + confirmText: + 'Select the users to add as members to [displayName], or drop a CSV file with a userPrincipalName column to bulk add members.', + multiPost: false, + allowResubmit: true, + }, { label: 'Set Global Address List Visibility', type: 'POST', @@ -360,16 +424,6 @@ const Page = () => { title={pageTitle} cardButton={ - {!reportDB.useReportDB && ( - <> - - - - )} @@ -380,27 +434,13 @@ const Page = () => { > Deploy Group Template - {reportDB.controls} } + dataSourceControls={reportDB.controls} apiUrl={reportDB.resolvedApiUrl} - apiData={ - reportDB.useReportDB - ? undefined - : showMembers - ? { expandMembers: true } - : showOwners - ? { expandOwners: true } - : {} - } + apiData={reportDB.useReportDB ? undefined : {}} queryKey={ - reportDB.useReportDB - ? reportDB.resolvedQueryKey - : showMembers - ? `groups-with-members-${currentTenant}` - : showOwners - ? `groups-with-owners-${currentTenant}` - : `groups-${currentTenant}` + reportDB.useReportDB ? reportDB.resolvedQueryKey : `groups-${currentTenant}` } actions={actions} offCanvas={offCanvas} @@ -419,6 +459,148 @@ const Page = () => { 'onPremisesSamAccountName', 'membershipRule', 'onPremisesSyncEnabled', + 'members', + 'owners', + ]} + subTables={[ + { + id: 'members', + header: 'Members', + label: 'View members', + cachedColumn: 'membersCsv', + table: { + title: 'Members of [displayName]', + queryKey: 'group-members-[id]', + api: { + url: '/api/ListGroups', + data: { groupID: '[id]', members: true, groupType: '[groupType]' }, + dataKey: 'members', + }, + simpleColumns: ['displayName', 'userPrincipalName', 'mail', '@odata.type'], + actions: [ + { + label: 'View User', + link: `/identity/administration/users/user?userId=[id]&tenantFilter=${nestedTenantQuery}`, + color: 'info', + icon: , + condition: (row) => + !row?.['@odata.type'] || row['@odata.type'] === '#microsoft.graph.user', + }, + { + label: 'View Group', + link: `/identity/administration/groups/group?groupId=[id]&tenantFilter=${nestedTenantQuery}`, + color: 'info', + icon: , + condition: (row) => row?.['@odata.type'] === '#microsoft.graph.group', + }, + { + label: 'Remove Member', + type: 'POST', + url: '/api/ExecGroupMembers', + icon: , + data: { action: '!removeMember', groupId: 'parent.id', users: 'id' }, + confirmText: 'Remove [displayName] from [parent.displayName]?', + condition: (row) => + !row?.parent?.dynamicGroupBool && !row?.parent?.membershipRule, + }, + ], + cardButton: { + label: 'Add Members', + icon: , + url: '/api/ExecGroupMembers', + allowResubmit: true, + relatedQueryKeys: 'group-members-[id]', + confirmText: 'Add members to [displayName]?', + condition: (row) => !row?.dynamicGroupBool && !row?.membershipRule, + data: { action: '!addMember', groupId: 'id' }, + fields: [ + { + type: 'autoComplete', + name: 'users', + label: 'Add Members', + multiple: true, + creatable: false, + csvColumn: 'userPrincipalName', + api: { + url: '/api/ListUsersAndGroups', + dataKey: 'Results', + valueField: 'id', + labelField: 'displayName', + descriptionField: 'userPrincipalName', + }, + }, + ], + }, + }, + }, + { + id: 'owners', + header: 'Owners', + label: 'View owners', + cachedColumn: 'ownersCsv', + table: { + title: 'Owners of [displayName]', + queryKey: 'group-owners-[id]', + api: { + url: '/api/ListGroups', + data: { groupID: '[id]', owners: true, groupType: '[groupType]' }, + dataKey: 'owners', + }, + simpleColumns: ['displayName', 'userPrincipalName', 'mail'], + actions: [ + { + label: 'View User', + link: `/identity/administration/users/user?userId=[id]&tenantFilter=${nestedTenantQuery}`, + color: 'info', + icon: , + condition: (row) => + !row?.['@odata.type'] || row['@odata.type'] === '#microsoft.graph.user', + }, + { + label: 'Remove Owner', + type: 'POST', + url: '/api/ExecGroupMembers', + icon: , + data: { action: '!removeOwner', groupId: 'parent.id', users: 'id' }, + confirmText: 'Remove [displayName] as owner of [parent.displayName]?', + }, + ], + cardButton: { + label: 'Add Owners', + icon: , + url: '/api/ExecGroupMembers', + allowResubmit: true, + relatedQueryKeys: 'group-owners-[id]', + confirmText: 'Add owners to [displayName]?', + data: { action: '!addOwner', groupId: 'id' }, + fields: [ + { + type: 'autoComplete', + name: 'users', + label: 'Add Owners', + multiple: true, + creatable: false, + csvColumn: 'userPrincipalName', + api: { + url: '/api/ListGraphRequest', + dataKey: 'Results', + valueField: 'id', + labelField: 'displayName', + descriptionField: 'userPrincipalName', + data: { + Endpoint: 'users', + manualPagination: true, + $select: 'id,userPrincipalName,displayName', + $count: true, + $orderby: 'displayName', + $top: 999, + }, + }, + }, + ], + }, + }, + }, ]} /> {reportDB.syncDialog} diff --git a/src/pages/identity/administration/guest-users/index.js b/src/pages/identity/administration/guest-users/index.js new file mode 100644 index 000000000000..0056c50626c7 --- /dev/null +++ b/src/pages/identity/administration/guest-users/index.js @@ -0,0 +1,241 @@ +import { useMemo, useState } from 'react' +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' +import { ApiGetCallWithPagination } from '../../../../api/ApiCall' +import { useSettings } from '../../../../hooks/use-settings' +import { useCippReportDB } from '../../../../components/CippComponents/CippReportDBControls' +import { + Card, + CardActionArea, + CardContent, + Skeleton, + Stack, + Typography, +} from '@mui/material' +import { Box, Grid } from '@mui/system' +import { EyeIcon } from '@heroicons/react/24/outline' +import { + Block, + CheckCircle, + GroupOutlined, + HourglassEmpty, + PersonOff, + Send, + WarningAmber, +} from '@mui/icons-material' + +const GUEST_STATUSES = [ + { status: 'Active', color: 'success', icon: CheckCircle }, + { status: 'Stale', color: 'error', icon: WarningAmber }, + { status: 'Pending Acceptance', color: 'warning', icon: HourglassEmpty }, + { status: 'Never Signed In', color: 'info', icon: PersonOff }, + { status: 'Disabled', color: 'secondary', icon: Block }, +] + +const SummaryCard = ({ + title, + count, + icon: Icon, + color, + selected, + isFetching, + onClick, +}) => ( + + + + + + + + {isFetching ? : count} + + + {title} + + + + + + +) + +const Page = () => { + const pageTitle = 'Guest Users' + const currentTenant = useSettings().currentTenant + const [statusFilter, setStatusFilter] = useState(null) + + const reportDB = useCippReportDB({ + apiUrl: '/api/ListGuestUsers', + queryKey: 'ListGuestUsers', + cacheName: 'Guests', + syncTitle: 'Sync Guest Users', + allowToggle: true, + defaultCached: true, + allowAllTenantSync: true, + cacheColumns: ['CacheTimestamp'], + }) + + // Same url/data/queryKey as the table below, so react-query shares one request + // between the summary cards and the table. + const guestData = ApiGetCallWithPagination({ + url: reportDB.resolvedApiUrl, + data: { tenantFilter: currentTenant }, + queryKey: reportDB.resolvedQueryKey, + waiting: true, + }) + + const guests = useMemo( + () => + guestData.data?.pages?.flatMap((page) => + Array.isArray(page) ? page : [] + ) ?? [], + [guestData.data] + ) + + const statusCounts = useMemo(() => { + const counts = {} + for (const guest of guests) { + counts[guest.status] = (counts[guest.status] ?? 0) + 1 + } + return counts + }, [guests]) + + // The trailing column-format entry drives the table's status filter from the + // summary cards; an empty value clears it again. The named presets surface the + // same one-click filters in the table's filter menu. + const filterList = useMemo( + () => [ + ...GUEST_STATUSES.map(({ status }) => ({ + filterName: `${status} guests`, + value: [{ id: 'status', value: status }], + type: 'column', + })), + { id: 'status', value: statusFilter ?? '' }, + ], + [statusFilter] + ) + + const toggleStatusFilter = (status) => + setStatusFilter((current) => (current === status ? null : status)) + + const tableFilter = ( + + + setStatusFilter(null)} + /> + + {GUEST_STATUSES.map(({ status, color, icon }) => ( + + toggleStatusFilter(status)} + /> + + ))} + + ) + + const actions = [ + { + label: 'View User', + link: '/identity/administration/users/user?userId=[id]', + multiPost: false, + icon: , + color: 'success', + }, + { + label: 'Re-invite Guest', + type: 'POST', + icon: , + url: '/api/AddGuest', + data: { displayName: 'displayName', mail: 'mail', sendInvite: '!true' }, + confirmText: 'Are you sure you want to re-send the invitation to [mail]?', + multiPost: false, + condition: (row) => + !!row.mail && + (row.status === 'Pending Acceptance' || row.status === 'Stale'), + }, + ] + + const offCanvas = { + extendedInfoFields: [ + 'displayName', + 'userPrincipalName', + 'mail', + 'id', + 'status', + 'externalUserState', + 'externalUserStateChangeDateTime', + 'createdDateTime', + 'lastSignInDateTime', + 'lastInteractiveSignInDateTime', + 'lastNonInteractiveSignInDateTime', + 'lastSuccessfulSignInDateTime', + 'daysSinceSignIn', + 'accountEnabled', + 'sourceDomain', + 'sponsors', + ], + actions: actions, + } + + const simpleColumns = [ + ...reportDB.cacheColumns, + 'displayName', + 'mail', + 'sourceDomain', + 'status', + 'accountEnabled', + 'createdDateTime', + 'lastSignInDateTime', + 'daysSinceSignIn', + ] + + return ( + <> + + {reportDB.syncDialog} + + ) +} + +Page.getLayout = (page) => ( + {page} +) + +export default Page diff --git a/src/pages/identity/administration/users/user/bec.jsx b/src/pages/identity/administration/users/user/bec.jsx index faac9f2e1dc5..af143ece582b 100644 --- a/src/pages/identity/administration/users/user/bec.jsx +++ b/src/pages/identity/administration/users/user/bec.jsx @@ -7,6 +7,7 @@ import CalendarIcon from '@heroicons/react/24/outline/CalendarIcon' import { Download, Mail, Fingerprint, Launch } from '@mui/icons-material' import { HeaderedTabbedLayout } from '../../../../../layouts/HeaderedTabbedLayout' import tabOptions from './tabOptions' +import { CippUserSwitcher } from '../../../../../components/CippComponents/CippUserSwitcher' import ReactTimeAgo from 'react-time-ago' import { CippCopyToClipBoard } from '../../../../../components/CippComponents/CippCopyToClipboard' import { Box, Stack } from '@mui/system' @@ -455,6 +456,13 @@ const Page = () => { + } subtitle={subtitle} isFetching={userRequest.isFetching} > @@ -469,7 +477,7 @@ const Page = () => { > {/* Remediation Card */} - + { /> {/* Check 1 Card with Loading */} - + { > {/* Remediation Card */} - + { /> {/* All Steps */} - + diff --git a/src/pages/identity/administration/users/user/conditional-access.jsx b/src/pages/identity/administration/users/user/conditional-access.jsx index 8449148b8562..0ef3dfc43811 100644 --- a/src/pages/identity/administration/users/user/conditional-access.jsx +++ b/src/pages/identity/administration/users/user/conditional-access.jsx @@ -7,6 +7,7 @@ import CalendarIcon from "@heroicons/react/24/outline/CalendarIcon"; import { Mail, Fingerprint, Launch } from "@mui/icons-material"; import { HeaderedTabbedLayout } from "../../../../../layouts/HeaderedTabbedLayout"; import tabOptions from "./tabOptions"; +import { CippUserSwitcher } from "../../../../../components/CippComponents/CippUserSwitcher"; import ReactTimeAgo from "react-time-ago"; import { CippCopyToClipBoard } from "../../../../../components/CippComponents/CippCopyToClipboard"; import { Box, Stack, Typography, Button } from "@mui/material"; @@ -95,6 +96,13 @@ const Page = () => { + } subtitle={subtitle} isFetching={userRequest.isLoading} > diff --git a/src/pages/identity/administration/users/user/edit.jsx b/src/pages/identity/administration/users/user/edit.jsx index fac7c794366a..f4d7fbf0ad2a 100644 --- a/src/pages/identity/administration/users/user/edit.jsx +++ b/src/pages/identity/administration/users/user/edit.jsx @@ -12,6 +12,7 @@ import CalendarIcon from '@heroicons/react/24/outline/CalendarIcon' import { Mail, Fingerprint, Launch } from '@mui/icons-material' import { HeaderedTabbedLayout } from '../../../../../layouts/HeaderedTabbedLayout' import tabOptions from './tabOptions' +import { CippUserSwitcher } from '../../../../../components/CippComponents/CippUserSwitcher' import { CippCopyToClipBoard } from '../../../../../components/CippComponents/CippCopyToClipboard' import { CippTimeAgo } from '../../../../../components/CippComponents/CippTimeAgo' import { Button, Alert } from '@mui/material' @@ -155,6 +156,13 @@ const Page = () => { + } subtitle={subtitle} isFetching={userRequest.isLoading} > diff --git a/src/pages/identity/administration/users/user/exchange.jsx b/src/pages/identity/administration/users/user/exchange.jsx index f2ef68719bcd..ca1b39c65aa6 100644 --- a/src/pages/identity/administration/users/user/exchange.jsx +++ b/src/pages/identity/administration/users/user/exchange.jsx @@ -20,6 +20,7 @@ import { } from '@mui/icons-material' import { HeaderedTabbedLayout } from '../../../../../layouts/HeaderedTabbedLayout' import tabOptions from './tabOptions' +import { CippUserSwitcher } from '../../../../../components/CippComponents/CippUserSwitcher' import { CippTimeAgo } from '../../../../../components/CippComponents/CippTimeAgo' import { CippCopyToClipBoard } from '../../../../../components/CippComponents/CippCopyToClipboard' import { Box, Stack } from '@mui/system' @@ -1407,6 +1408,13 @@ const Page = () => { + } subtitle={subtitle} actions={CippExchangeActions()} actionsData={userRequest.data?.[0]?.MailboxActionsData} @@ -1451,7 +1459,9 @@ const Page = () => { 'Microsoft.Exchange.Configuration.Tasks.ManagementObjectNotFoundException' ) && ( <> - + {/* Stacked below lg — a 4/8 split at phone widths leaves both columns too + narrow to hold a label, breaking the text one word per line. */} + { handleRefresh={() => userRequest.refetch()} /> - + { <> Location - + { ]} /> - + { + } actions={userActions} actionsData={data} subtitle={subtitle} isFetching={userRequest.isLoading} > - {userRequest.isLoading && } + {/* The loading state is the loaded page's own scaffold with each card in its + skeleton form — generic form-row bars looked nothing like what replaces them + and left the rest of the viewport empty. */} + {userRequest.isLoading && ( + + + + + + + + {['Latest Logon', 'Applied Conditional Access Policies', 'Multi-Factor Authentication Devices', 'Memberships'].map( + (section) => ( + + {section} + + + ) + )} + + + + + )} {userRequest.isSuccess && ( - + {/* Stacked below lg — at phone widths a 4/8 split leaves both columns too + narrow to hold a label, breaking the text one word per line. */} + - + Latest Logon { value: [{ id: "Name", value: "CA Exclusion" }], type: "column", }, + { + filterName: "Location Alerts", + value: [{ id: "Name", value: "Location Alert Exclusion" }], + type: "column", + }, { filterName: "Mailbox Permissions", value: [{ id: "Name", value: "Mailbox Vacation" }], diff --git a/src/pages/identity/reports/inactive-users-report/index.js b/src/pages/identity/reports/inactive-users-report/index.js index 3ed0ef6c286f..e04dc5d1dd7c 100644 --- a/src/pages/identity/reports/inactive-users-report/index.js +++ b/src/pages/identity/reports/inactive-users-report/index.js @@ -61,6 +61,7 @@ const Page = () => { "createdDateTime", "lastSignInDateTime", "lastNonInteractiveSignInDateTime", + "lastSuccessfulSignInDateTime", "numberOfAssignedLicenses", "daysSinceLastSignIn", "lastRefreshedDateTime", @@ -75,6 +76,7 @@ const Page = () => { "displayName", "lastSignInDateTime", "lastNonInteractiveSignInDateTime", + "lastSuccessfulSignInDateTime", "numberOfAssignedLicenses", "daysSinceLastSignIn", ...reportDB.cacheColumns.filter((c) => c !== "Tenant"), @@ -89,7 +91,7 @@ const Page = () => { actions={actions} offCanvas={offCanvas} simpleColumns={simpleColumns} - cardButton={reportDB.controls} + dataSourceControls={reportDB.controls} /> {reportDB.syncDialog} diff --git a/src/pages/identity/reports/mfa-report/index.js b/src/pages/identity/reports/mfa-report/index.js index 668030f9f923..efe9e34a5c04 100644 --- a/src/pages/identity/reports/mfa-report/index.js +++ b/src/pages/identity/reports/mfa-report/index.js @@ -117,7 +117,7 @@ const Page = () => { simpleColumns={simpleColumns} filters={filters} actions={actions} - cardButton={reportDB.controls} + dataSourceControls={reportDB.controls} initialFilters={urlFilters} /> {reportDB.syncDialog} diff --git a/src/pages/identity/reports/signin-report/index.js b/src/pages/identity/reports/signin-report/index.js index c835fa6ccf4b..c21dda6804f0 100644 --- a/src/pages/identity/reports/signin-report/index.js +++ b/src/pages/identity/reports/signin-report/index.js @@ -242,7 +242,7 @@ const Page = () => { /> - + diff --git a/src/pages/security/reports/mde-onboarding/index.js b/src/pages/security/reports/mde-onboarding/index.js index 955ec17fa66a..aa88b8c7516a 100644 --- a/src/pages/security/reports/mde-onboarding/index.js +++ b/src/pages/security/reports/mde-onboarding/index.js @@ -355,7 +355,7 @@ const Page = () => { "partnerUnresponsivenessThresholdInDays", "CacheTimestamp", ]} - cardButton={reportDB.controls} + dataSourceControls={reportDB.controls} /> {reportDB.syncDialog} diff --git a/src/pages/teams-share/onedrive/index.js b/src/pages/teams-share/onedrive/index.js index 9b4f9029c08d..bb4fa1c56d1c 100644 --- a/src/pages/teams-share/onedrive/index.js +++ b/src/pages/teams-share/onedrive/index.js @@ -119,7 +119,7 @@ const Page = () => { queryKey={reportDB.resolvedQueryKey} actions={actions} simpleColumns={simpleColumns} - cardButton={reportDB.controls} + dataSourceControls={reportDB.controls} /> {reportDB.syncDialog} diff --git a/src/pages/teams-share/permissions-report/index.js b/src/pages/teams-share/permissions-report/index.js index 61d24b785e0e..12dcbd1c913b 100644 --- a/src/pages/teams-share/permissions-report/index.js +++ b/src/pages/teams-share/permissions-report/index.js @@ -13,6 +13,7 @@ import { ApiGetCall } from '../../../api/ApiCall' import { useSettings } from '../../../hooks/use-settings' import { Alert, Button, Container, Stack, SvgIcon, Typography } from '@mui/material' import { Grid } from '@mui/system' +import { CippExpandableAlert } from '../../../components/CippComponents/CippExpandableAlert' import { BuildingOfficeIcon, CloudArrowDownIcon, @@ -253,7 +254,7 @@ const Page = () => { )} - + Applies To shows how far each permission reaches.{' '} Whole site is a permission on the site itself, which every library that still inherits also gets. This library only means that library was detached @@ -261,7 +262,7 @@ const Page = () => { that still inherit are not listed — their permissions are the site's, so everything here is either the site's own permissions or a deliberate exception to them. - + { > Bulk Add Sites - {reportDB.controls} ) @@ -766,6 +765,7 @@ const Page = () => { offCanvas={offCanvas} simpleColumns={simpleColumns} cardButton={pageActions} + dataSourceControls={reportDB.controls} tableFilter={ <> diff --git a/src/pages/teams-share/sharepoint2/index.js b/src/pages/teams-share/sharepoint2/index.js new file mode 100644 index 000000000000..6020001b4e71 --- /dev/null +++ b/src/pages/teams-share/sharepoint2/index.js @@ -0,0 +1,322 @@ +import { useEffect, useMemo, useState } from 'react' +import { useRouter } from 'next/router' +import { Container, IconButton, Stack, Tooltip, Typography } from '@mui/material' +import { Grid } from '@mui/system' +import { Delete, FolderOpen, Launch, Refresh, Storage as StorageIcon } from '@mui/icons-material' +import { Layout as DashboardLayout } from '../../../layouts/index.js' +import { CippHead } from '../../../components/CippComponents/CippHead' +import { CippSharePointBrowserBanner } from '../../../components/CippComponents/CippSharePointBrowserBanner' +import { CippSharePointBrowserProperties } from '../../../components/CippComponents/CippSharePointBrowserProperties' +import { CippSharePointBrowserPermissions } from '../../../components/CippComponents/CippSharePointBrowserPermissions' +import { CippSharePointBrowserStorage } from '../../../components/CippComponents/CippSharePointBrowserStorage' +import { CippSharePointFolderView } from '../../../components/CippComponents/CippSharePointFolderView' +import { ApiGetCall } from '../../../api/ApiCall' +import { useSettings } from '../../../hooks/use-settings' + +const openUrls = (rows) => { + const list = Array.isArray(rows) ? rows : [rows] + list.forEach((row) => { + if (row?.webUrl) { + window.open(row.webUrl, '_blank', 'noopener,noreferrer') + } + }) +} + +const queryString = (value) => (typeof value === 'string' && value.length > 0 ? value : null) + +const isSiteRow = (row) => row?.type === 'site' + +const Page = () => { + const router = useRouter() + const tenantFilter = useSettings().currentTenant + const [checkedIds, setCheckedIds] = useState([]) + const [permissionsOpen, setPermissionsOpen] = useState(false) + const [storageOpen, setStorageOpen] = useState(false) + + // Location is owned by the URL (?siteId=…) — name/url come from navigation or API Site + const siteId = queryString(router.query.siteId) + const [siteMeta, setSiteMeta] = useState(null) + + const openedSite = + router.isReady && siteId + ? { + id: siteId, + webUrl: siteMeta?.id === siteId ? siteMeta.webUrl : undefined, + displayName: siteMeta?.id === siteId ? siteMeta.displayName || siteMeta.webUrl : '…', + type: 'site', + canOpen: true, + storageUsedInBytes: + siteMeta?.id === siteId ? siteMeta.storageUsedInBytes : undefined, + } + : null + const path = openedSite ? [openedSite] : [] + const atRoot = !openedSite + + // Browser back/forward changes location without going through handlers + useEffect(() => { + setCheckedIds([]) + }, [siteId]) + + const setBrowserLocation = (site) => { + if (!router.isReady) return + const query = { ...router.query } + if (site?.id) { + query.siteId = site.id + setSiteMeta(site) + } else { + delete query.siteId + setSiteMeta(null) + } + delete query.siteUrl + delete query.siteName + delete query.siteType + router.replace({ pathname: router.pathname, query }, undefined, { shallow: true }) + } + + const browserApi = ApiGetCall({ + url: '/api/ListSiteBrowser', + data: { + tenantFilter, + ...(siteId ? { SiteId: siteId } : {}), + }, + queryKey: siteId + ? `ListSiteBrowser-${tenantFilter}-${siteId}` + : `ListSiteBrowser-${tenantFilter}-root`, + waiting: router.isReady && !!tenantFilter && tenantFilter !== 'AllTenants', + }) + + // Enrich from API after cold load / refresh + useEffect(() => { + const site = browserApi.data?.Site + if (site?.id && site.id === siteId) { + setSiteMeta((prev) => ({ + ...prev, + ...site, + // Keep storage from the site we opened if the Site payload doesn't include it + storageUsedInBytes: site.storageUsedInBytes ?? prev?.storageUsedInBytes, + })) + } + }, [browserApi.data?.Site, siteId]) + + const rawResults = browserApi.data?.Results + const items = useMemo(() => { + if (!Array.isArray(rawResults)) return [] + return rawResults.map((row) => ({ + ...row, + canOpen: row.type === 'site', + })) + }, [rawResults]) + + const checkedItems = useMemo(() => { + if (!checkedIds.length) return [] + const idSet = new Set(checkedIds) + return items.filter((item) => idSet.has(item.id)) + }, [items, checkedIds]) + + // Single checked row drives properties / permissions; multi-check is for Actions only. + const selected = checkedItems.length === 1 ? checkedItems[0] : null + + const actionRows = useMemo(() => { + if (checkedItems.length) return checkedItems + if (openedSite?.webUrl) return [openedSite] + return [] + }, [checkedItems, openedSite]) + + const errorMessage = + typeof rawResults === 'string' + ? rawResults + : browserApi.isError + ? (browserApi.error?.message ?? 'Failed to load items.') + : null + + // Banner always reflects the opened site; library only when one is selected + const bannerSite = openedSite ?? (selected?.type === 'site' ? selected : null) + const bannerLibrary = selected?.type === 'library' ? selected : null + const propertiesItem = selected + + // Storage is site-scoped: selected site at root, or the opened site when drilled in + const storageSite = isSiteRow(selected) ? selected : openedSite + const showStorage = Boolean(storageSite?.webUrl) + + const handleCheckedChange = (ids) => { + setCheckedIds(ids) + } + + const handleOpen = (item) => { + if (!item?.canOpen) return + setCheckedIds([]) + setBrowserLocation(item) + } + + const handleNavigate = (nextPath) => { + setCheckedIds([]) + setBrowserLocation(nextPath?.[0] ?? null) + } + + const bulkActions = useMemo( + () => [ + { + label: 'Open in SharePoint', + icon: , + showInActionsMenu: true, + noConfirm: true, + customFunction: (rows) => openUrls(rows), + condition: (rows) => + (Array.isArray(rows) ? rows : [rows]).some((row) => Boolean(row?.webUrl)), + }, + { + label: 'Storage', + icon: , + showInActionsMenu: true, + noConfirm: true, + condition: (rows) => { + const list = Array.isArray(rows) ? rows : [rows] + return list.length === 1 && isSiteRow(list[0]) && Boolean(list[0]?.webUrl) + }, + customFunction: (rows) => { + const list = Array.isArray(rows) ? rows : [rows] + if (list[0]?.id) setCheckedIds([list[0].id]) + setStorageOpen(true) + }, + }, + { + label: 'Delete', + icon: , + showInActionsMenu: true, + noConfirm: true, + customFunction: () => {}, + }, + ], + [] + ) + + const rowActions = useMemo( + () => [ + { + label: 'Open in SharePoint', + icon: , + condition: (item) => Boolean(item?.webUrl), + href: (item) => item.webUrl, + }, + { + label: 'Browse', + icon: , + condition: (item) => Boolean(item?.canOpen), + onClick: handleOpen, + }, + { + label: 'Storage', + icon: , + condition: (item) => isSiteRow(item) && Boolean(item?.webUrl), + onClick: (item) => { + if (item?.id) setCheckedIds([item.id]) + setStorageOpen(true) + }, + }, + { + label: 'Delete', + icon: , + onClick: () => {}, + }, + ], + [] + ) + + return ( + <> + + + + + SharePoint Site Browser + + + browserApi.refetch()} + disabled={!tenantFilter || tenantFilter === 'AllTenants' || browserApi.isFetching} + > + + + + + + {!tenantFilter || tenantFilter === 'AllTenants' ? ( + + Select a tenant to browse SharePoint sites. + + ) : ( + <> + setStorageOpen(true)} + showPermissions={selected?.type === 'site' || selected?.type === 'library'} + onPermissionsClick={() => setPermissionsOpen(true)} + showEditSite={Boolean(openedSite) || isSiteRow(selected)} + queryKeys={ + siteId + ? `ListSiteBrowser-${tenantFilter}-${siteId}` + : `ListSiteBrowser-${tenantFilter}-root` + } + /> + setPermissionsOpen(false)} + item={selected} + tenantFilter={tenantFilter} + siteUrl={selected?.type === 'library' ? openedSite?.webUrl : selected?.webUrl} + siteId={selected?.type === 'library' ? openedSite?.id : selected?.id} + /> + setStorageOpen(false)} + item={storageSite} + tenantFilter={tenantFilter} + /> + + + + + + + + + + )} + + + + ) +} + +Page.getLayout = (page) => {page} + +export default Page diff --git a/src/pages/teams-share/sharing-report/index.js b/src/pages/teams-share/sharing-report/index.js index 38958272b110..5f28b1969dd8 100644 --- a/src/pages/teams-share/sharing-report/index.js +++ b/src/pages/teams-share/sharing-report/index.js @@ -87,7 +87,7 @@ const SharingLinkDetail = ({ row }) => { {properties .filter((prop) => prop.value !== undefined && prop.value !== null && prop.value !== '') .map((prop) => ( - + {prop.label} diff --git a/src/pages/teams-share/teams/business-voice/index.js b/src/pages/teams-share/teams/business-voice/index.js index 2a600064ca8e..72f80d1dda12 100644 --- a/src/pages/teams-share/teams/business-voice/index.js +++ b/src/pages/teams-share/teams/business-voice/index.js @@ -139,7 +139,7 @@ const Page = () => { "Complex: AssignmentStatus eq Unassigned; AcquiredCapabilities like UserAssignment", }, ]} - cardButton={reportDB.controls} + dataSourceControls={reportDB.controls} /> {reportDB.syncDialog} diff --git a/src/pages/teams-share/teams/list-team/index.js b/src/pages/teams-share/teams/list-team/index.js index 99b51994bafe..82f175d82544 100644 --- a/src/pages/teams-share/teams/list-team/index.js +++ b/src/pages/teams-share/teams/list-team/index.js @@ -62,9 +62,9 @@ const Page = () => { - {reportDB.controls} } + dataSourceControls={reportDB.controls} /> {reportDB.syncDialog} diff --git a/src/pages/teams-share/teams/teams-activity/index.js b/src/pages/teams-share/teams/teams-activity/index.js index 69d3fa3ebc72..bc3f825da9cd 100644 --- a/src/pages/teams-share/teams/teams-activity/index.js +++ b/src/pages/teams-share/teams/teams-activity/index.js @@ -39,7 +39,7 @@ const Page = () => { "CallCount", "TeamsChat", ]} - cardButton={reportDB.controls} + dataSourceControls={reportDB.controls} /> {reportDB.syncDialog} diff --git a/src/pages/tenant/administration/add-subscription/index.jsx b/src/pages/tenant/administration/add-subscription/index.jsx index 457f350b3d3b..9792db06d0e9 100644 --- a/src/pages/tenant/administration/add-subscription/index.jsx +++ b/src/pages/tenant/administration/add-subscription/index.jsx @@ -54,7 +54,7 @@ const Page = () => { {/* Conditional Access Policy Selector */} - + { sortOptions={true} /> - + { sx={{ mb: 2 }} key={event.id} > - + { }} /> - + { ]} /> - + {/* Show textField for String properties when NOT using in/notIn operators */} { - + { const pageTitle = 'Alerts' @@ -29,6 +37,37 @@ const Page = () => { color: 'success', target: '_self', }, + { + label: 'Enable Alert', + type: 'POST', + url: '/api/ExecToggleAlert', + data: { + ID: 'RowKey', + EventType: 'EventType', + Disabled: '!false', + }, + icon: , + relatedQueryKeys: 'ListAlertsQueue', + condition: (row) => row.Enabled !== true, + confirmText: 'Are you sure you want to enable this alert?', + multiPost: false, + }, + { + label: 'Disable Alert', + type: 'POST', + url: '/api/ExecToggleAlert', + data: { + ID: 'RowKey', + EventType: 'EventType', + Disabled: '!true', + }, + icon: , + relatedQueryKeys: 'ListAlertsQueue', + condition: (row) => row.Enabled === true, + confirmText: + 'Are you sure you want to disable this alert? It will not run until you enable it again.', + multiPost: false, + }, { label: 'Delete Alert', type: 'POST', @@ -62,6 +101,7 @@ const Page = () => { simpleColumns={[ 'Tenants', 'EventType', + 'Enabled', 'Conditions', 'RepeatsEvery', 'Actions', diff --git a/src/pages/tenant/administration/applications/app-registration/index.jsx b/src/pages/tenant/administration/applications/app-registration/index.jsx index 7bcfe12fad20..8c5e25707661 100644 --- a/src/pages/tenant/administration/applications/app-registration/index.jsx +++ b/src/pages/tenant/administration/applications/app-registration/index.jsx @@ -14,6 +14,7 @@ import { Badge, } from '@mui/icons-material' import { HeaderedTabbedLayout } from '../../../../../layouts/HeaderedTabbedLayout' +import { CippAppRegistrationSwitcher } from '../../../../../components/CippComponents/CippAppRegistrationSwitcher' import tabOptions from './tabOptions' import { CippCopyToClipBoard } from '../../../../../components/CippComponents/CippCopyToClipboard' import { Box, Stack } from '@mui/system' @@ -368,6 +369,13 @@ const Page = () => { + } subtitle={subtitle} actions={appData ? appActions : []} actionsData={actionsData} @@ -390,7 +398,7 @@ const Page = () => { > - + @@ -478,7 +486,7 @@ const Page = () => { - + Credentials { + } subtitle={subtitle} actions={appData ? appActions : []} actionsData={actionsData} diff --git a/src/pages/tenant/administration/applications/enterprise-app/index.jsx b/src/pages/tenant/administration/applications/enterprise-app/index.jsx index fb17f8e88994..48d5116cf579 100644 --- a/src/pages/tenant/administration/applications/enterprise-app/index.jsx +++ b/src/pages/tenant/administration/applications/enterprise-app/index.jsx @@ -6,6 +6,7 @@ import CippFormSkeleton from '../../../../../components/CippFormPages/CippFormSk import CalendarIcon from '@heroicons/react/24/outline/CalendarIcon' import { Fingerprint, Launch, Apps, Group, CheckCircle, Warning, Badge } from '@mui/icons-material' import { HeaderedTabbedLayout } from '../../../../../layouts/HeaderedTabbedLayout' +import { CippEnterpriseAppSwitcher } from '../../../../../components/CippComponents/CippEnterpriseAppSwitcher' import tabOptions from './tabOptions' import { CippCopyToClipBoard } from '../../../../../components/CippComponents/CippCopyToClipboard' import { Box, Stack } from '@mui/system' @@ -288,6 +289,13 @@ const Page = () => { + } subtitle={subtitle} actions={spData ? appActions : []} actionsData={actionsData} @@ -305,7 +313,7 @@ const Page = () => { - + @@ -393,7 +401,7 @@ const Page = () => { - + Credentials { + } subtitle={subtitle} actions={spData ? appActions : []} actionsData={actionsData} diff --git a/src/pages/tenant/administration/tenants/edit.js b/src/pages/tenant/administration/tenants/edit.js index cc6d59397910..f8214e55b6e7 100644 --- a/src/pages/tenant/administration/tenants/edit.js +++ b/src/pages/tenant/administration/tenants/edit.js @@ -71,6 +71,7 @@ const Page = () => { ClearImmutableId: false, DisableOneDriveSharing: false, removeCalendarPermissions: false, + OOO: "", postExecution: { psa: false, email: false, @@ -122,6 +123,7 @@ const Page = () => { ClearImmutableId: false, DisableOneDriveSharing: false, removeCalendarPermissions: false, + OOO: "", postExecution: { psa: false, email: false, diff --git a/src/pages/tenant/baselines/alignment/index.js b/src/pages/tenant/baselines/alignment/index.js index a595d8ec2333..7f643e3bef01 100644 --- a/src/pages/tenant/baselines/alignment/index.js +++ b/src/pages/tenant/baselines/alignment/index.js @@ -65,6 +65,7 @@ import { CippDataTable } from '../../../../components/CippTable/CippDataTable' import { CippQueueTracker } from '../../../../components/CippTable/CippQueueTracker' import { CippHead } from '../../../../components/CippComponents/CippHead' import { CippInfoBar } from '../../../../components/CippCards/CippInfoBar' +import { CippChartCard } from '../../../../components/CippCards/CippChartCard' import CippButtonCard from '../../../../components/CippCards/CippButtonCard' import { CippApiDialog } from '../../../../components/CippComponents/CippApiDialog' import { CippApiLogsDrawer } from '../../../../components/CippComponents/CippApiLogsDrawer' @@ -556,6 +557,13 @@ const Page = () => { }) const catalog = definitionsApi.data ?? [] + // A per-path deny queues an OBJECT deletion, so it only exists where the + // definition ships a delete executor (the detect-drift standards, where each + // path IS a policy). Ordinary standards get accept-only per-property actions - + // enforcing the baseline is the row-level Deny. + const supportsPathDeletion = (standardName) => + !!catalog.find((entry) => entry.name === `${standardName}`.split('#')[0]) + ?.delete const baselines = baselinesApi.data ?? [] const standardAggregates = aggregateApi.data?.standards ?? [] const tenant = { @@ -1309,23 +1317,24 @@ const Page = () => { Accept this property only )} - {!acceptedPath && ( - - )} + {!acceptedPath && + supportsPathDeletion(row.standardName) && ( + + )} ) @@ -1469,20 +1478,22 @@ const Page = () => { Accept this property only )} - {drifted && !acceptedPath && ( - - )} + {drifted && + !acceptedPath && + supportsPathDeletion(row.standardName) && ( + + )} ) @@ -1672,6 +1683,32 @@ const Page = () => { : 'None', }, ])} + {/* A single point is just today's live score (already listed above) - the + chart earns its space once there is an actual line to draw. */} + {Array.isArray(row.trend) && row.trend.length > 1 && ( + + ({ + x: point.date, + y: point.aligned, + })), + }, + { + name: 'Compliant with baseline', + data: row.trend.map((point) => ({ + x: point.date, + y: point.verified, + })), + }, + ]} + /> + + )} { + // Force the value onto every standard: dirty + touched so the form registers + // the change even on fields the operator never interacted with. stage.standards.forEach((instanceKey) => { - formControl.setValue(`${instanceKey}.${field}`, value) + formControl.setValue(`${instanceKey}.${field}`, value, { + shouldDirty: true, + shouldTouch: true, + }) }) } @@ -227,16 +238,24 @@ const StagePanel = ({ }), standards: stage.standards.map((instanceKey) => { const config = values[instanceKey] ?? {} + // A standard the operator never expanded never mounts its settings fields, + // so its variables never enter the form - serialize the SAVED variables for + // those, or saving a large baseline would silently wipe their configuration. + // Unwrapped either way: legacy saves stored option objects ({label, value}) + // for some variables, and passing them through verbatim keeps that debt alive. + const savedVariables = + stage.standardConfigs?.[instanceKey]?.variables ?? {} return { standard: instanceKey.split('#')[0], instance: instanceKey, variables: Object.fromEntries( - Object.entries(config.variables ?? {}).map(([key, value]) => [ - key, - unwrapValue(value), - ]) + Object.entries(config.variables ?? savedVariables).map( + ([key, value]) => [key, unwrapValue(value)] + ) ), - remediateEnabled: config.remediateEnabled ?? true, + // Report-only unless the operator explicitly enabled remediation - a + // missing value must never fail open into auto-fixing tenants. + remediateEnabled: config.remediateEnabled ?? false, alertEnabled: config.alertEnabled ?? true, alertOnRemediate: config.alertOnRemediate ?? false, } @@ -326,8 +345,8 @@ const StagePanel = ({ )} {conditionIds.map((conditionId) => { const conditionType = get( - watchForm, - `conditions.${conditionId}.type` + watchConditions, + `${conditionId}.type` )?.value return ( @@ -522,6 +541,10 @@ const Page = () => { const router = useRouter() const [activeStage, setActiveStage] = useState(0) const [loadedTemplateId, setLoadedTemplateId] = useState(null) + // The GUID the next save updates. Null means the save CREATES a baseline (new + // editor, or a clone before its first save); the save response's id is adopted + // so saving twice never creates twice. + const [saveTargetId, setSaveTargetId] = useState(null) const [stages, setStages] = useState(() => buildEditorStages(undefined)) const [dialogOpen, setDialogOpen] = useState(false) const [dialogStageIndex, setDialogStageIndex] = useState(0) @@ -541,6 +564,23 @@ const Page = () => { // and table), all alignment views for every tenant, and the standards catalog. const saveBaseline = ApiPostCall({ relatedQueryKeys: ['ListBaseline*'], + onResult: (result) => { + const savedId = result?.Metadata?.id + if (!savedId) return + // Adopt the saved baseline: the next save updates it instead of creating a + // duplicate, and the URL reflects it so a refresh keeps editing the same one. + // Matching loadedTemplateId also stops the render-phase loader from + // re-resetting the form when the refetched list arrives. + setSaveTargetId(savedId) + setLoadedTemplateId(savedId) + if (router.query.id !== savedId || router.query.clone) { + router.replace( + { pathname: router.pathname, query: { id: savedId } }, + undefined, + { shallow: true } + ) + } + }, }) // After a save, the natural next step is seeing where the tenants stand - offer a // no-changes check right away instead of ending the setup flow in silence. @@ -579,6 +619,7 @@ const Page = () => { // Render-phase reset (not an effect) so the switch happens before anything paints. if (template && template.GUID !== loadedTemplateId) { setLoadedTemplateId(template.GUID) + setSaveTargetId(router.query.clone ? null : template.GUID) setStages(buildEditorStages(template)) setActiveStage(0) setHasUnsavedChanges(false) @@ -791,7 +832,7 @@ const Page = () => { saveBaseline.mutate({ url: '/api/AddBaseline', data: { - GUID: router.query.clone ? undefined : (loadedTemplateId ?? undefined), + GUID: saveTargetId ?? undefined, templateName: values.templateName, description: values.description, // Send the selector's option objects as-is (label/value/type) so they can be diff --git a/src/pages/tenant/gdap-management/invites/add.js b/src/pages/tenant/gdap-management/invites/add.js index fd46d263d0b8..660644782135 100644 --- a/src/pages/tenant/gdap-management/invites/add.js +++ b/src/pages/tenant/gdap-management/invites/add.js @@ -6,6 +6,7 @@ import CippPageCard from "../../../../components/CippCards/CippPageCard"; import { ApiGetCall, ApiPostCall } from "../../../../api/ApiCall"; import { CippDataTable } from "../../../../components/CippTable/CippDataTable"; import { CippApiResults } from "../../../../components/CippComponents/CippApiResults"; +import { CippExpandableAlert } from "../../../../components/CippComponents/CippExpandableAlert"; import { Accordion, AccordionDetails, @@ -99,7 +100,7 @@ const Page = () => { - + Use this form to generate invites for the selected GDAP Role Template. After generating the invite, you will receive two URLs: @@ -122,7 +123,7 @@ const Page = () => { {" "} in Application Settings. - + {createDefaults && ( <> diff --git a/src/pages/tenant/gdap-management/offboarding.js b/src/pages/tenant/gdap-management/offboarding.js index aa6cddc87d0d..5cfc5be94fbf 100644 --- a/src/pages/tenant/gdap-management/offboarding.js +++ b/src/pages/tenant/gdap-management/offboarding.js @@ -226,6 +226,12 @@ const Page = () => { label="Remove all Domain Analyser results for this tenant." type="switch" /> + diff --git a/src/pages/tenant/gdap-management/relationships/relationship/index.js b/src/pages/tenant/gdap-management/relationships/relationship/index.js index 7a93d35548c2..df4c3f3004ca 100644 --- a/src/pages/tenant/gdap-management/relationships/relationship/index.js +++ b/src/pages/tenant/gdap-management/relationships/relationship/index.js @@ -3,6 +3,7 @@ import { useRouter } from "next/router"; import { ApiGetCall } from "../../../../../api/ApiCall"; import CippFormSkeleton from "../../../../../components/CippFormPages/CippFormSkeleton"; import { HeaderedTabbedLayout } from "../../../../../layouts/HeaderedTabbedLayout"; +import { CippGdapRelationshipSwitcher } from "../../../../../components/CippComponents/CippGdapRelationshipSwitcher"; import tabOptions from "./tabOptions.json"; import { Box, Grid, Stack } from "@mui/system"; import { CippTimeAgo } from "../../../../../components/CippComponents/CippTimeAgo"; @@ -135,6 +136,7 @@ const Page = () => { } subtitle={subtitle} isFetching={relationshipRequest.isLoading} actions={CippGdapActions()} diff --git a/src/pages/tenant/gdap-management/relationships/relationship/mappings.js b/src/pages/tenant/gdap-management/relationships/relationship/mappings.js index b9669e3f725f..383dfc279451 100644 --- a/src/pages/tenant/gdap-management/relationships/relationship/mappings.js +++ b/src/pages/tenant/gdap-management/relationships/relationship/mappings.js @@ -2,6 +2,7 @@ import { Layout as DashboardLayout } from "../../../../../layouts/index.js"; import { useRouter } from "next/router"; import { ApiGetCall } from "../../../../../api/ApiCall"; import { HeaderedTabbedLayout } from "../../../../../layouts/HeaderedTabbedLayout"; +import { CippGdapRelationshipSwitcher } from "../../../../../components/CippComponents/CippGdapRelationshipSwitcher"; import tabOptions from "./tabOptions.json"; import { CippTimeAgo } from "../../../../../components/CippComponents/CippTimeAgo"; import { CippDataTable } from "../../../../../components/CippTable/CippDataTable"; @@ -45,6 +46,7 @@ const Page = () => { } subtitle={subtitle} isFetching={relationshipRequest.isLoading} backUrl="/tenant/gdap-management/relationships" diff --git a/src/pages/tenant/gdap-management/roles/add.js b/src/pages/tenant/gdap-management/roles/add.js index 6c2e2a808f22..3cd90ac53a9e 100644 --- a/src/pages/tenant/gdap-management/roles/add.js +++ b/src/pages/tenant/gdap-management/roles/add.js @@ -13,6 +13,7 @@ import cippDefaults from "../../../../data/CIPPDefaultGDAPRoles"; import { ApiGetCall } from "../../../../api/ApiCall"; import { Settings, SyncAlt } from "@mui/icons-material"; import { CippDataTable } from "../../../../components/CippTable/CippDataTable"; +import { CippExpandableAlert } from "../../../../components/CippComponents/CippExpandableAlert"; import { TrashIcon } from "@heroicons/react/24/outline"; const Page = () => { @@ -217,7 +218,7 @@ const Page = () => { compareType="is" compareValue={true} > - + In Advanced Mode, you can manually map existing groups to GDAP roles. This functionality is designed to help map existing groups to GDAP roles that do not @@ -244,7 +245,7 @@ const Page = () => { on GDAP Role Guidance. - + { mt: 2, }} > - + { sx={{ height: "100%", display: "flex", flexDirection: "column" }} > Backup History - + {settings.currentTenant === "AllTenants" && ( - + { display: "flex", justifyContent: "space-between", alignItems: "flex-start", + flexWrap: "wrap", + rowGap: 1, + columnGap: 1, }} > - + {(() => { const match = backup.name.match( @@ -485,7 +499,12 @@ const Page = () => { /> )} - + - + {pageTitle} @@ -144,7 +144,7 @@ const Page = () => { {currentTenant === "AllTenants" && layoutMode !== "Table" ? ( - + { <> {blockCards.map((block, index) => ( { disable the schedule. After conversion, please check the new templates to ensure they are correct and re-enable the schedule. - + - + diff --git a/src/pages/tenant/tools/geoiplookup/index.js b/src/pages/tenant/tools/geoiplookup/index.js index 58b739e439f1..ba3fb20f6d6a 100644 --- a/src/pages/tenant/tools/geoiplookup/index.js +++ b/src/pages/tenant/tools/geoiplookup/index.js @@ -88,13 +88,13 @@ const Page = () => { > - + - + { required /> - + + + Just activated a role through PIM? Re-check your access. + + + + ), } return ( @@ -115,6 +171,9 @@ const Page = ({ reason = 'session' }) => { {isSessionEnded ? 'Sign in - CIPP' : '401 - Access Denied'} + {/* If an impersonated role can't load /me, this page is what renders — the exit + affordance must exist here or the user is stuck until they clear localStorage. */} + {(orgData.isSuccess || swaStatus.isSuccess) && Array.isArray(userRoles) && ( { disableRipple: true, }, }, + MuiAccordionDetails: { + styleOverrides: { + // An accordion is almost always nested inside a card that already pays for gutters, + // and its own content usually adds a third layer. Halve the horizontal padding on a + // phone so the innermost text is not reading through 70px of chrome. + root: { + "@media (max-width: 899.95px)": { + paddingLeft: 8, + paddingRight: 8, + }, + }, + }, + }, + MuiAccordionSummary: { + styleOverrides: { + root: { + "@media (max-width: 899.95px)": { + paddingLeft: 8, + paddingRight: 8, + }, + }, + }, + }, MuiCardActions: { styleOverrides: { root: { @@ -78,6 +101,10 @@ export const createComponents = () => { paddingLeft: 24, paddingRight: 24, paddingTop: 16, + "@media (max-width: 899.95px)": { + paddingLeft: 16, + paddingRight: 16, + }, }, }, }, @@ -88,6 +115,13 @@ export const createComponents = () => { paddingLeft: 24, paddingRight: 24, paddingTop: 20, + // 48px of the 390 a phone has is 12% of the screen spent on one card's gutters, + // and cards nest — a card inside an accordion inside a page card pays it three + // times over. Vertical padding is left alone; it isn't what runs out. + "@media (max-width: 899.95px)": { + paddingLeft: 16, + paddingRight: 16, + }, }, }, }, @@ -98,6 +132,11 @@ export const createComponents = () => { paddingLeft: 24, paddingRight: 24, paddingTop: 16, + // Matches MuiCardContent, or the header would sit inset from its own card body. + "@media (max-width: 899.95px)": { + paddingLeft: 16, + paddingRight: 16, + }, }, subheader: { fontSize: 14, @@ -176,6 +215,24 @@ export const createComponents = () => { }, }, }, + MuiDialog: { + styleOverrides: { + paper: { + // Below md a centred dialog wastes both screen edges and clips long forms, and + // there are ~70 dialogs in the app that never opted into fullScreen. Give them + // the full width and the full available height here rather than per call site. + // Height stays content-driven, so a two-line confirmation doesn't become an + // empty full screen. Dialogs that already pass fullScreen are unaffected. + "@media (max-width: 899.95px)": { + margin: 0, + width: "100%", + maxWidth: "100%", + maxHeight: "100%", + borderRadius: 0, + }, + }, + }, + }, MuiDialogActions: { styleOverrides: { root: { @@ -186,6 +243,21 @@ export const createComponents = () => { "&>:not(:first-of-type)": { marginLeft: 16, }, + "@media (max-width: 899.95px)": { + // 32px of side padding is a lot of a 390px screen. + paddingBottom: 16, + paddingLeft: 16, + paddingRight: 16, + paddingTop: 16, + // Spacing as gap, not margin-left. `:first-of-type` counts per element type, so an + // actions row of [caption div, button, button] gave the FIRST button no margin and + // the second 16px — invisible in a row, but once the row stacks on a phone the two + // buttons sit at different left edges and different widths. gap works either way. + gap: 8, + "&>:not(:first-of-type)": { + marginLeft: 0, + }, + }, }, }, }, @@ -232,9 +304,17 @@ export const createComponents = () => { root: { borderRadius: 6, padding: 8, + // Touch devices get 44px hit targets without changing desktop density — + // pointer:coarse only matches touch-primary input. + "@media (pointer: coarse)": { + padding: 10, + }, }, sizeSmall: { padding: 4, + "@media (pointer: coarse)": { + padding: 8, + }, }, }, }, @@ -254,6 +334,11 @@ export const createComponents = () => { styleOverrides: { input: { fontSize: 14, + // iOS Safari zooms the viewport when a focused input's text is under 16px, and + // never zooms back out. Touch devices get 16px; pointer devices keep 14. + "@media (pointer: coarse)": { + fontSize: 16, + }, height: "40px", // Apply height only to single-line inputs "&.MuiInputBase-inputMultiline": { height: "unset", // Allow textareas to be flexible @@ -291,6 +376,11 @@ export const createComponents = () => { input: { padding: "0 12px", // Adds padding to the left and right of the text fontSize: 14, + // iOS Safari zooms the viewport when a focused input's text is under 16px, and + // never zooms back out. Touch devices get 16px; pointer devices keep 14. + "@media (pointer: coarse)": { + fontSize: 16, + }, height: "40px", // Height for single-line input fields only "&.MuiInputBase-inputMultiline": { height: "unset", // Exclude multiline inputs (textareas) from fixed height @@ -427,6 +517,17 @@ export const createComponents = () => { }, }, }, + MuiTooltip: { + defaultProps: { + // MUI's Tooltip attaches no touchmove and no scroll listener, so a press held + // through a scroll opens the tooltip after 700ms and nothing is scheduled to close + // it until the finger lifts — it rides the page as you drag. A tooltip is a hover + // affordance and touch has no hover, so the long-press variant is not worth the + // defect. Sites that genuinely want one opt back in with disableTouchListener={false} + // (CippJSONView's field descriptions are the only one). + disableTouchListener: true, + }, + }, MuiTextField: { defaultProps: { variant: "filled", diff --git a/src/utils/cippVersion.js b/src/utils/cippVersion.js index ed64050c0759..4f20325c8d14 100644 --- a/src/utils/cippVersion.js +++ b/src/utils/cippVersion.js @@ -26,12 +26,17 @@ export async function getCippVersion() { return fetchPromise; } +import { getImpersonatedRole } from "./impersonation"; + // Build headers including X-CIPP-Version. Accept extra headers to merge. export async function buildVersionedHeaders(extra = {}) { const version = await getCippVersion(); + // Backend honors this only for real superadmins; harmless for everyone else. + const impersonatedRole = getImpersonatedRole(); return { "Content-Type": "application/json", "X-CIPP-Version": version, + ...(impersonatedRole ? { "x-cipp-impersonate-role": impersonatedRole } : {}), ...extra, }; } diff --git a/src/utils/csv-field-values.js b/src/utils/csv-field-values.js new file mode 100644 index 000000000000..7a61882af298 --- /dev/null +++ b/src/utils/csv-field-values.js @@ -0,0 +1,50 @@ +/** + * Pull values from CSV rows for a named column (case-insensitive, trimmed header match). + */ +export const extractCsvColumnValues = (csvRows, csvColumn) => { + if (!csvColumn || !Array.isArray(csvRows) || csvRows.length === 0) { + return [] + } + const colLower = String(csvColumn).trim().toLowerCase() + return csvRows + .map((row) => { + if (!row || typeof row !== 'object') return null + const key = Object.keys(row).find((k) => k.trim().toLowerCase() === colLower) + return key ? String(row[key]).trim() : null + }) + .filter((v) => v != null && v !== '') +} + +/** + * Flatten autocomplete form values to plain string ids/UPNs. + */ +export const normalizeAutoCompleteValues = (value) => { + const items = Array.isArray(value) ? value : value != null && value !== '' ? [value] : [] + return items + .filter(Boolean) + .map((item) => + typeof item === 'object' && item?.value != null + ? String(item.value) + : item != null + ? String(item) + : null + ) + .filter(Boolean) +} + +/** + * Merge autocomplete + optional CSV companion field (`${name}__csv`) into a flat string array. + */ +export const mergeCsvFormFields = (formData, fields) => { + if (!fields?.length) return formData + const merged = { ...formData } + fields.forEach((field) => { + if (!field.csvColumn || !field.name) return + const csvFieldName = `${field.name}__csv` + const acValues = normalizeAutoCompleteValues(merged[field.name]) + const csvValues = extractCsvColumnValues(merged[csvFieldName], field.csvColumn) + merged[field.name] = [...acValues, ...csvValues] + delete merged[csvFieldName] + }) + return merged +} diff --git a/src/utils/get-cipp-formatting.js b/src/utils/get-cipp-formatting.js index cbc0673f0257..30b4b2809596 100644 --- a/src/utils/get-cipp-formatting.js +++ b/src/utils/get-cipp-formatting.js @@ -43,6 +43,22 @@ const getCountryNameFromCode = (countryCode) => { return country ? country.Name : countryCode } +// Shared so the card list and the extended-info drawer can label a portal link with the +// same glyph the table cell uses. +export const portalIcons = { + portal_m365: CogIcon, + portal_exchange: MailOutline, + portal_entra: UserIcon, + portal_teams: UsersIcon, + portal_azure: ServerIcon, + portal_intune: LaptopWindows, + portal_security: Shield, + portal_compliance: CompassCalibration, + portal_sharepoint: Description, + portal_platform: PrecisionManufacturing, + portal_bi: BarChart, +} + export const getCippFormatting = ( data, cellName, @@ -63,20 +79,6 @@ export const getCippFormatting = ( ) } - const portalIcons = { - portal_m365: CogIcon, - portal_exchange: MailOutline, - portal_entra: UserIcon, - portal_teams: UsersIcon, - portal_azure: ServerIcon, - portal_intune: LaptopWindows, - portal_security: Shield, - portal_compliance: CompassCalibration, - portal_sharepoint: Description, - portal_platform: PrecisionManufacturing, - portal_bi: BarChart, - } - // Create a helper function to render chips with CollapsibleChipList const renderChipList = (items, maxItems = 4) => { if (!Array.isArray(items) || items.length === 0) { @@ -266,6 +268,9 @@ export const getCippFormatting = ( 'NextAttemptUtc', 'LastErrorUtc', 'LastPolledUtc', + 'QueuedUtc', // Worker health job queue + 'StartedUtc', // Worker health job queue + 'CompletedUtc', // Worker health job queue ] if (absoluteDateArray.includes(cellName)) { if (data === null || data === undefined || data === '') { @@ -274,9 +279,11 @@ export const getCippFormatting = ( const dt = parseCippDate(data) if (isNaN(dt.getTime())) return isText ? '' : '' if (dt.getTime() === 0) return isText ? '' : 'Never' - // text mode: Date object so MRT sorts chronologically (toLocaleString for CSV export); + // text mode: Date object so MRT sorts chronologically — except when the caller can + // receive a rendered node ('both': off-canvas, card views) or explicitly wants a + // string (false: CSV export); a raw Date is not a valid React child. // cell mode: long absolute string in the browser's locale + timezone. - if (isText) return canReceive === false ? dt.toLocaleString() : dt + if (isText) return canReceive === 'both' || canReceive === false ? dt.toLocaleString() : dt return dt.toLocaleString() } @@ -317,6 +324,7 @@ export const getCippFormatting = ( 'requestDate', // App Consent Requests 'reviewedDate', // App Consent Requests 'GeneratedAt', // Report Builder + 'RecordedAt', // Container update history 'directTenantAuthDate', // Direct tenant service account 'ServiceAccountLastAuth', // Direct tenant service account ] diff --git a/src/utils/get-filtered-portals.js b/src/utils/get-filtered-portals.js new file mode 100644 index 000000000000..d4f00dff1ecd --- /dev/null +++ b/src/utils/get-filtered-portals.js @@ -0,0 +1,37 @@ +import Portals from "../data/portals"; + +// Which M365 portal links the user wants shown, resolved from user-specific settings +// (preferred), tenant-level settings, or the all-on defaults. Pure so both the dashboard +// menu and the mobile FAB sheet share one filter (and it stays unit-testable). +export const getFilteredPortals = (settings) => { + const defaultLinks = { + M365_Portal: true, + Exchange_Portal: true, + Entra_Portal: true, + Teams_Portal: true, + Azure_Portal: true, + Intune_Portal: true, + SharePoint_Admin: true, + Security_Portal: true, + Compliance_Portal: true, + Power_Platform_Portal: true, + Power_BI_Portal: true, + }; + + let portalLinks; + if (settings?.UserSpecificSettings?.portalLinks) { + portalLinks = { + ...defaultLinks, + ...settings.UserSpecificSettings.portalLinks, + }; + } else if (settings?.portalLinks) { + portalLinks = { ...defaultLinks, ...settings.portalLinks }; + } else { + portalLinks = defaultLinks; + } + + return Portals.filter((portal) => { + const settingKey = portal.name; + return settingKey ? portalLinks[settingKey] === true : true; + }); +}; diff --git a/src/utils/help-links.js b/src/utils/help-links.js new file mode 100644 index 000000000000..8de5cb2ed923 --- /dev/null +++ b/src/utils/help-links.js @@ -0,0 +1,41 @@ +// Help/support destinations shared by CippSpeedDial (desktop FAB) and AccountPopover +// (mobile, where the FAB corner belongs to page actions). One definition so the two +// surfaces can't drift. + +export const getHelpLinks = (pathname = "") => [ + { + id: "bug-report", + name: "Report Bug", + href: "https://github.com/CyberDrain/CIPP/issues/new?template=bug.yml", + }, + { + id: "feature-request", + name: "Request Feature", + href: "https://github.com/CyberDrain/CIPP/issues/new?template=feature.yml", + }, + { + id: "discord", + name: "Join the Discord!", + href: "https://discord.gg/cyberdrain", + }, + { + id: "documentation", + name: "Check the Documentation", + href: `https://docs.cipp.app/user-documentation${pathname}`, + }, +]; + +// Clears the TanStack Query cache (memory + the persisted localStorage copy) and hard-reloads. +export const clearCippCache = (queryClient) => { + queryClient.clear(); + + if (typeof window !== "undefined") { + Object.keys(localStorage).forEach((key) => { + if (key.startsWith("REACT_QUERY_OFFLINE_CACHE")) { + localStorage.removeItem(key); + } + }); + // Force refresh the page to bypass browser cache and reload JavaScript + window.location.reload(true); + } +}; diff --git a/src/utils/impersonation.js b/src/utils/impersonation.js new file mode 100644 index 000000000000..a9e7d9a75b4e --- /dev/null +++ b/src/utils/impersonation.js @@ -0,0 +1,75 @@ +/** + * Role impersonation state (superadmin-only feature). + * + * Lives in its own localStorage key - NOT app.settings, which round-trips to the server + * via ExecUserSettings and races on init - so it is readable synchronously from + * non-React code (buildVersionedHeaders) and via useSyncExternalStore in components. + * The backend only honors the header for real superadmins, so this state can never + * grant privileges; it only narrows them. + */ + +const KEY = 'cipp_impersonate_role' +const listeners = new Set() +const notify = () => listeners.forEach((listener) => listener()) + +// localStorage throws in locked-down browsers - never let that break the app. +export const getImpersonatedRole = () => { + if (typeof window === 'undefined') return null + try { + return window.localStorage.getItem(KEY) || null + } catch { + return null + } +} + +export const subscribeImpersonation = (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) +} + +// Everything except authmecipp is persisted to localStorage (REACT_QUERY_OFFLINE_CACHE*), +// so both transitions must clear the persisted cache and hard-reload or role-scoped data +// from the other identity survives. Mirrors the "Clear Cache and Reload" speed-dial in +// _app.js. Never use queryClient.cancelQueries() here (permanent-abort race). +const clearCachesAndReload = (queryClient) => { + try { + queryClient?.clear() + } catch { + /* reload still gives a clean slate */ + } + try { + Object.keys(window.localStorage) + .filter((key) => key.startsWith('REACT_QUERY_OFFLINE_CACHE')) + .forEach((key) => window.localStorage.removeItem(key)) + } catch { + /* worst case: stale cache entries expire on their own */ + } + window.location.reload() +} + +export const enterImpersonation = (role, queryClient) => { + try { + window.localStorage.setItem(KEY, String(role).toLowerCase()) + } catch { + return + } + notify() + clearCachesAndReload(queryClient) +} + +export const exitImpersonation = (queryClient) => { + try { + window.localStorage.removeItem(KEY) + } catch { + /* fall through - reload clears in-memory state regardless */ + } + notify() + clearCachesAndReload(queryClient) +} + +// The Craft response cache keys on URL + params, not headers - impersonated GETs carry +// this param so the two identities can never share a cached response. +export const impersonationCacheParams = () => { + const role = getImpersonatedRole() + return role ? { _imp: role } : {} +} diff --git a/src/utils/is-cloud-pc-device.js b/src/utils/is-cloud-pc-device.js new file mode 100644 index 000000000000..cda4c5259da4 --- /dev/null +++ b/src/utils/is-cloud-pc-device.js @@ -0,0 +1,13 @@ +// Windows 365 Cloud PCs never report BitLocker (isEncrypted stays false) although their disks +// are platform-encrypted by Azure, so encryption reporting must not flag them as unencrypted. +// Mirrors the backend Test-CIPPCloudPCDevice check: the cached CIPP marker, the documented +// deviceType signal (chassisType kept in case the service starts emitting it), then the +// model/manufacturer pair Windows 365 provisions. +export const isCloudPcDevice = (device) => + device?.isCloudPC === true || + device?.deviceType === 'cloudPC' || + device?.chassisType === 'cloudPC' || + (typeof device?.model === 'string' && + device.model.toLowerCase().startsWith('cloud pc') && + typeof device?.manufacturer === 'string' && + device.manufacturer.toLowerCase() === 'microsoft corporation') diff --git a/src/utils/overlay-history.js b/src/utils/overlay-history.js new file mode 100644 index 000000000000..c5d997fb9779 --- /dev/null +++ b/src/utils/overlay-history.js @@ -0,0 +1,123 @@ +/** + * Makes the phone's back gesture dismiss the topmost overlay instead of leaving the page. + * + * A back swipe IS history navigation, so the only way to intercept it is to own a history + * entry. An overlay that opts in pushes one entry at the SAME url — nothing visible + * changes — carrying a depth marker; swiping back pops that entry and we close the overlay + * rather than letting the router move. + * + * Two details keep this safe next to Next's pages router: + * + * 1. The pushed state CLONES the router's current state (__N/url/as/key) and only adds + * the marker. If the user navigates away with an overlay open, our entry is still a + * valid route entry, so returning to it renders that page instead of dead-ending on a + * state Next refuses to recognise. + * 2. router.beforePopState() suppresses Next's same-url re-render for pops that are ours. + * Without it, closing a drawer would emit route events and reset the scroll position — + * a long list would jump back to the top every time you dismissed a row. + * + * Module-level rather than per-component because the stack has to be shared: a back press + * must close the deepest open overlay, whoever rendered it. + */ + +const MARKER = "__cippOverlay"; + +// Entries mirror the history entries we pushed, deepest last. +let stack = []; +// The marker depth of the entry the browser is currently sitting on. Tracked here because +// beforePopState runs after window.history has already moved, so the previous depth is +// otherwise unknowable. +let depth = 0; +// history.back() calls WE made. The resulting popstate must not be mistaken for the user's. +let selfPops = 0; +let installed = false; + +const hasWindow = () => typeof window !== "undefined" && typeof window.history !== "undefined"; + +const readDepth = () => { + if (!hasWindow()) return 0; + const value = window.history.state?.[MARKER]; + return typeof value === "number" ? value : 0; +}; + +const handlePopState = () => { + const next = readDepth(); + const wasSelfPop = selfPops > 0; + if (wasSelfPop) selfPops -= 1; + depth = next; + + // Pop deepest-first: closing bottom-up would briefly leave an overlay covering one that + // is still open. Entries released by their own component are already gone from the stack, + // so a self-pop normally finds nothing here. + const dismissed = []; + while (stack.length > 0 && stack[stack.length - 1].depth > next) { + dismissed.push(stack.pop()); + } + dismissed.forEach((entry) => { + if (entry.released) return; + entry.released = true; + entry.close?.(); + }); +}; + +/** + * Next calls this from its own popstate listener, before ours. Returning false means "the + * app handled this" and Next skips the route change entirely. + */ +const shouldNextHandle = (state) => { + if (selfPops > 0) return false; + if (stack.length === 0) return true; + // Not a pop out of an overlay entry — a forward move or an unrelated traversal. + if (readDepth() >= depth) return true; + // A real navigation that happens to jump past our entries still belongs to Next. + const top = stack[stack.length - 1]; + if (state?.as && top.as && state.as !== top.as) return true; + return false; +}; + +export const installOverlayHistory = (router) => { + if (installed || !hasWindow()) return; + installed = true; + depth = readDepth(); + window.addEventListener("popstate", handlePopState); + router?.beforePopState?.(shouldNextHandle); +}; + +export const pushOverlayEntry = (close) => { + if (!hasWindow()) return null; + const entry = { + depth: readDepth() + 1, + as: window.history.state?.as, + close, + released: false, + }; + stack.push(entry); + depth = entry.depth; + window.history.pushState({ ...window.history.state, [MARKER]: entry.depth }, ""); + return entry; +}; + +export const releaseOverlayEntry = (entry) => { + if (!entry || entry.released) return; + entry.released = true; + const index = stack.indexOf(entry); + if (index === -1) return; + const isTop = index === stack.length - 1; + stack.splice(index, 1); + // Only the entry the browser is actually sitting on can be popped. If something was + // pushed over ours — another overlay, or a route change while we were open — ours is + // buried: drop it and leave history alone rather than yanking the user backwards. + if (!isTop || !hasWindow() || readDepth() !== entry.depth) return; + selfPops += 1; + depth = entry.depth - 1; + window.history.back(); +}; + +// Module state outlives a render tree, so tests need a way back to zero. +export const resetOverlayHistory = () => { + if (hasWindow()) window.removeEventListener("popstate", handlePopState); + stack = []; + depth = 0; + selfPops = 0; + installed = false; +}; diff --git a/src/utils/permission-rules.js b/src/utils/permission-rules.js new file mode 100644 index 000000000000..56f8a6048a24 --- /dev/null +++ b/src/utils/permission-rules.js @@ -0,0 +1,180 @@ +/** + * Permission rule helpers for custom roles. + * + * Rules use the same include/exclude glob format as base roles (cipp-roles.json): + * patterns match against "Category.Object.Read|ReadWrite" strings, exclude wins. + * Matching mirrors PowerShell -like: * is the only wildcard, case-insensitive. + */ + +const escapeRegex = (str) => str.replace(/[.+?^${}()|[\]\\]/g, '\\$&') + +export const matchPattern = (pattern, value) => { + if (typeof pattern !== 'string' || typeof value !== 'string') return false + const regex = new RegExp( + `^${escapeRegex(pattern).replace(/\*/g, '.*')}$`, + 'i' + ) + return regex.test(value) +} + +// Flatten the ExecAPIPermissionList tree ({Cat: {Obj: {Read|ReadWrite: {...}}}}) +// into the sorted list of concrete permission strings. +export const flattenPermissionTree = (apiPermissions) => { + const universe = [] + if (!apiPermissions || typeof apiPermissions !== 'object') return universe + Object.keys(apiPermissions).forEach((cat) => { + Object.keys(apiPermissions[cat] || {}).forEach((obj) => { + Object.keys(apiPermissions[cat][obj] || {}).forEach((type) => { + universe.push(`${cat}.${obj}.${type}`) + }) + }) + }) + return universe.sort() +} + +const normalizeRuleList = (list) => + (Array.isArray(list) ? list : []) + .map((entry) => (typeof entry === 'string' ? entry : entry?.value)) + .filter((entry) => typeof entry === 'string' && entry.length > 0) + +/** + * Expand include/exclude rules over a permission universe. + * Returns the matched permissions plus per-pattern stats for the live preview: + * - includeCounts: pattern -> total universe matches + * - excludeCounts: pattern -> included permissions this pattern removed + * - excludedBy: permission -> first exclude pattern that removed it + */ +export const expandRules = (rules, universe) => { + const include = normalizeRuleList(rules?.Include) + const exclude = normalizeRuleList(rules?.Exclude) + const includeCounts = {} + const excludeCounts = {} + const excludedBy = {} + include.forEach((pattern) => (includeCounts[pattern] = 0)) + exclude.forEach((pattern) => (excludeCounts[pattern] = 0)) + + const matched = [] + ;(universe || []).forEach((permission) => { + let included = false + include.forEach((pattern) => { + if (matchPattern(pattern, permission)) { + includeCounts[pattern] += 1 + included = true + } + }) + if (!included) return + const excludedByPattern = exclude.find((pattern) => + matchPattern(pattern, permission) + ) + if (excludedByPattern !== undefined) { + excludeCounts[excludedByPattern] += 1 + excludedBy[permission] = excludedByPattern + return + } + matched.push(permission) + }) + + return { matched, includeCounts, excludeCounts, excludedBy } +} + +/** + * Convert rules into the flat editor/storage map: { "CatObj": "Cat.Obj.None|Read|ReadWrite" }. + * ReadWrite beats Read; CIPP.Core is floored at Read (login breaks without it). + */ +export const rulesToFlatMap = (rules, apiPermissions) => { + const flat = {} + if (!apiPermissions || typeof apiPermissions !== 'object') return flat + const include = normalizeRuleList(rules?.Include) + const exclude = normalizeRuleList(rules?.Exclude) + + const granted = (permission) => + include.some((pattern) => matchPattern(pattern, permission)) && + !exclude.some((pattern) => matchPattern(pattern, permission)) + + Object.keys(apiPermissions).forEach((cat) => { + Object.keys(apiPermissions[cat] || {}).forEach((obj) => { + let level = 'None' + if (granted(`${cat}.${obj}.ReadWrite`)) { + level = 'ReadWrite' + } else if (granted(`${cat}.${obj}.Read`)) { + level = 'Read' + } + if (cat === 'CIPP' && obj === 'Core' && level === 'None') { + level = 'Read' + } + flat[`${cat}${obj}`] = `${cat}.${obj}.${level}` + }) + }) + return flat +} + +// Convert the flat map back into concrete-string rules (the canonical storage +// format for advanced-mode roles): Include = explicit non-None values. +export const flatMapToRules = (flatMap) => { + const include = [ + ...new Set( + Object.values(flatMap || {}).filter( + (value) => + typeof value === 'string' && + value.length > 0 && + !value.endsWith('.None') + ) + ), + ].sort() + return { Include: include, Exclude: [] } +} + +// 1-3 dot-separated segments of letters/digits/wildcards, e.g. "*", "*.Read", +// "Identity.User.*", "Identity.User.ReadWrite". Same grammar the backend enforces. +export const validateRulePattern = (str) => + typeof str === 'string' && /^[A-Za-z0-9*]+(\.[A-Za-z0-9*]+){0,2}$/.test(str) + +// Suggestion options for the rule autocompletes, grouped for CippAutocompleteGrouping. +export const buildRuleSuggestions = (apiPermissions) => { + const suggestions = [ + { label: '* (everything)', value: '*', category: 'Global' }, + { label: '*.Read (all read-only)', value: '*.Read', category: 'Global' }, + { + label: '*.ReadWrite (all read/write)', + value: '*.ReadWrite', + category: 'Global', + }, + ] + if (!apiPermissions || typeof apiPermissions !== 'object') return suggestions + Object.keys(apiPermissions) + .sort() + .forEach((cat) => { + suggestions.push({ + label: `${cat}.* (entire category)`, + value: `${cat}.*`, + category: cat, + }) + suggestions.push({ + label: `${cat}.*.Read`, + value: `${cat}.*.Read`, + category: cat, + }) + suggestions.push({ + label: `${cat}.*.ReadWrite`, + value: `${cat}.*.ReadWrite`, + category: cat, + }) + Object.keys(apiPermissions[cat] || {}) + .sort() + .forEach((obj) => { + suggestions.push({ + label: `${cat}.${obj}.*`, + value: `${cat}.${obj}.*`, + category: cat, + }) + Object.keys(apiPermissions[cat][obj] || {}).forEach((type) => { + suggestions.push({ + label: `${cat}.${obj}.${type}`, + value: `${cat}.${obj}.${type}`, + category: cat, + }) + }) + }) + }) + return suggestions +} diff --git a/src/utils/render-url-value.jsx b/src/utils/render-url-value.jsx new file mode 100644 index 000000000000..6757b9aa6f5d --- /dev/null +++ b/src/utils/render-url-value.jsx @@ -0,0 +1,58 @@ +import { Link, SvgIcon } from "@mui/material"; +import OpenInNew from "@mui/icons-material/OpenInNew"; +import { portalIcons } from "./get-cipp-formatting"; + +const ABSOLUTE_URL = /^https?:\/\//i; +// A bare host like "contoso-admin.sharepoint.com" — a portal link often arrives without +// its scheme, which would otherwise be resolved against the CIPP origin. +const HOST_LIKE = /^[\w-]+(\.[\w-]+)+(\/|$)/; + +/** + * A tappable, self-describing link for a URL-valued field. + * + * Table cells render portals as a bare icon, which reads fine under a narrow column header + * and not at all once the same value appears in a card or a labelled property list. + * Returns null when the value isn't linkable, so callers fall back to normal formatting. + */ +export const renderUrlValue = (value, field = "") => { + if (typeof value !== "string" || !value.trim()) return null; + + const isPortal = field.startsWith("portal_"); + const trimmed = value.trim(); + if (!isPortal && !ABSOLUTE_URL.test(trimmed)) return null; + + const href = ABSOLUTE_URL.test(trimmed) + ? trimmed + : HOST_LIKE.test(trimmed) + ? `https://${trimmed}` + : trimmed; + + const PortalIcon = portalIcons[field]; + + return ( + event.stopPropagation()} + sx={{ + display: "inline-flex", + alignItems: "center", + gap: 0.5, + minWidth: 0, + overflowWrap: "anywhere", + }} + > + {PortalIcon && ( + + + + )} + {isPortal ? "Open portal" : trimmed} + + + + + ); +}; diff --git a/src/utils/resolve-row-templates.js b/src/utils/resolve-row-templates.js new file mode 100644 index 000000000000..7a3656c7dbdc --- /dev/null +++ b/src/utils/resolve-row-templates.js @@ -0,0 +1,93 @@ +const TEMPLATE = /\[([^\]]+)\]/g + +/** + * Resolve a dotted path against an object. Missing segments yield undefined. + */ +export const getNestedValue = (source, path) => { + if (source === undefined || source === null) { + return undefined + } + if (!path) { + return source + } + + return path.split('.').reduce((acc, key) => { + if (acc === undefined || acc === null) { + return undefined + } + if (typeof acc !== 'object') { + return undefined + } + return acc[key] + }, source) +} + +/** + * Nested-table action context: `parent` is the opening row. If the child already + * had `parent` (API data), chain it at `parent.parent` unless the opening row is + * itself nested and already owns that slot. + */ +export const attachParentRow = (row, parentRow) => { + if (!parentRow || row == null) { + return row + } + if (Array.isArray(row)) { + return row.map((item) => attachParentRow(item, parentRow)) + } + if (row.parent === parentRow) { + return row + } + + let nextParent = parentRow + if (row.parent !== undefined && parentRow.parent === undefined) { + nextParent = { ...parentRow, parent: row.parent } + } + return { ...row, parent: nextParent } +} + +/** + * AllTenants convention used across CIPP: prefer the row (or nested parent) tenant. + */ +export const getRowTenant = (row, currentTenant) => { + if (currentTenant !== 'AllTenants') { + return currentTenant + } + const source = Array.isArray(row) ? row[0] : row + return ( + source?.Tenant || + source?.parent?.Tenant || + source?.tenantFilter || + source?.parent?.tenantFilter || + currentTenant + ) +} + +const replaceTemplatesInString = (value, row) => + value.replace(TEMPLATE, (_, key) => { + const resolved = getNestedValue(row, key) + if (resolved === undefined || resolved === null) { + return `[${key}]` + } + return String(resolved) + }) + +/** + * Walk strings (and objects/arrays of them) and replace `[field]` / `[nested.path]` + * from `row`. Booleans, numbers, and null stay as-is. + */ +export const resolveRowTemplates = (value, row) => { + if (typeof value === 'string') { + return replaceTemplatesInString(value, row) + } + if (Array.isArray(value)) { + return value.map((item) => resolveRowTemplates(item, row)) + } + if (value && typeof value === 'object') { + const next = {} + for (const key of Object.keys(value)) { + next[key] = resolveRowTemplates(value[key], row) + } + return next + } + return value +} diff --git a/src/utils/support-bundle.js b/src/utils/support-bundle.js new file mode 100644 index 000000000000..421d7b4d0c04 --- /dev/null +++ b/src/utils/support-bundle.js @@ -0,0 +1,246 @@ +import axios from 'axios' + +// Captures the API traffic behind the current page for the speed dial's support-file +// generator. The recorder is armed only while the support dialog is collecting: the dialog +// forces every active (mounted) query to refetch, so everything the page reads flows +// through axios inside the capture window and is recorded here — successes included, since +// support usually needs to see what the page DID get alongside what failed. + +// One oversized Graph list page must not balloon the bundle into something the user +// cannot email, so recorded bodies are capped and flagged instead of stored whole. +const MAX_BODY_CHARS = 262144 + +let armed = false +let seq = 0 +let calls = [] + +const serializeValue = (data, responseType) => { + if (data === null || data === undefined) return { value: null } + if ( + responseType === 'blob' || + (typeof Blob !== 'undefined' && data instanceof Blob) + ) { + return { + value: ``, + } + } + if (typeof FormData !== 'undefined' && data instanceof FormData) { + return { value: '' } + } + let text + try { + text = typeof data === 'string' ? data : JSON.stringify(data) + } catch { + text = String(data) + } + if (typeof text === 'string' && text.length > MAX_BODY_CHARS) { + return { value: text.slice(0, MAX_BODY_CHARS), truncated: true } + } + // Small bodies keep their shape so the bundle stays readable as plain JSON. + return { value: typeof data === 'string' ? data : data } +} + +// By response time axios has already transformed the request payload into its wire form, +// which for CIPP means a JSON string. Parse it back so the recorded requestBody is a +// readable object rather than an escaped string inside the bundle. +const parseMaybeJson = (data) => { + if (typeof data !== 'string') return data + const trimmed = data.trim() + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return data + try { + return JSON.parse(trimmed) + } catch { + return data + } +} + +const record = (config, response, error) => { + if (!config?.cippSupportMeta || config.cippSupportRecorded) return + // HMR in dev can register the interceptors more than once; the per-request flag + // keeps a call from being recorded twice. + config.cippSupportRecorded = true + const { start, seq: n } = config.cippSupportMeta + const entry = { + seq: n, + startedAt: new Date(start).toISOString(), + durationMs: Date.now() - start, + method: (config.method || 'get').toUpperCase(), + url: config.url, + params: config.params ?? null, + status: response?.status ?? null, + success: !error, + } + if (error) entry.errorMessage = String(error.message ?? error) + // The payload the client SENT matters as much as what came back - a failing write + // usually fails because of what was in it. + if (config.data !== undefined) { + const requestBody = serializeValue(parseMaybeJson(config.data)) + entry.requestBody = requestBody.value + if (requestBody.truncated) entry.requestBodyTruncated = true + } + const responseBody = serializeValue(response?.data, config.responseType) + entry.responseBody = responseBody.value + if (responseBody.truncated) entry.responseBodyTruncated = true + calls.push(entry) +} + +axios.interceptors.request.use((config) => { + if (armed) { + config.cippSupportMeta = { start: Date.now(), seq: ++seq } + } + return config +}) + +axios.interceptors.response.use( + (response) => { + record(response.config, response, null) + return response + }, + (error) => { + record(error?.config, error?.response, error ?? new Error('Request failed')) + return Promise.reject(error) + } +) + +export const armSupportRecorder = () => { + calls = [] + seq = 0 + armed = true +} + +export const disarmSupportRecorder = () => { + armed = false +} + +export const getSupportRecording = () => + [...calls].sort((a, b) => a.seq - b.seq) + +export const getSupportRecordingCount = () => calls.length + +const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +// A bearer token is a live credential, not an identifier — there is no support value in +// shipping one, so tokens are stripped from EVERY bundle regardless of the redaction +// option. Known token fields (/.auth/me's access_token, id_token, refresh_token and +// friends) are emptied by name, and anything shaped like a JWT is removed wherever it +// appears, error payloads included. base64url can never contain a quote or backslash, +// so the substitution cannot break the serialized JSON. +const TOKEN_FIELD_PATTERN = + /"([A-Za-z0-9_]*(?:access|id|refresh|session)_token[A-Za-z0-9_]*)":"[^"]*"/g +const JWT_PATTERN = /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]*/g + +export const stripTokens = (bundle) => { + let text = JSON.stringify(bundle) + let removed = 0 + text = text.replace(TOKEN_FIELD_PATTERN, (match, key) => { + removed++ + return `"${key}":""` + }) + text = text.replace(JWT_PATTERN, () => { + removed++ + return '' + }) + return { bundle: JSON.parse(text), removed } +} + +const EMAIL_PATTERN = /[A-Za-z0-9._%+'-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g +const GUID_PATTERN = + /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi +const ONMICROSOFT_PATTERN = + /[A-Za-z0-9-]+\.(?:mail\.)?onmicrosoft\.(?:com|us|de)/g + +// Replaces every email address, GUID (tenant and object ids alike) and known tenant +// domain with a consistent token: the same original value always maps to the same token, +// so support can still correlate "user3 appears in the failing call and the roles list" +// without seeing who user3 is. Domains are redacted from a harvested set (email domains, +// *.onmicrosoft.* matches and the selected tenant) rather than a blind hostname regex, +// so Graph schema strings and infrastructure URLs are never mangled. +// Works on the serialized bundle: none of the matched values or tokens can contain a +// quote or backslash, so the JSON structure survives the substitution. +export const redactBundle = (bundle, { keepHostnames = [] } = {}) => { + let text = JSON.stringify(bundle) + const emailMap = new Map() + const guidMap = new Map() + const domainMap = new Map() + const domainSet = new Set() + // Kept hostnames must survive even when a harvested domain is their suffix — the + // instance hostname often shares the MSP's own mail domain. Swap them for inert + // placeholders first (nothing the email/domain/GUID patterns can match), and swap + // them back after every substitution has run. + const keepTokens = new Map() + for (const host of keepHostnames.filter(Boolean)) { + const token = `__CIPP_KEEP_${keepTokens.size}__` + keepTokens.set(token, host) + text = text.replace(new RegExp(escapeRegExp(host), 'gi'), token) + } + + // Harvest tenant domains before emails are replaced, so bare occurrences of an + // email's domain are caught too. + for (const match of text.matchAll(EMAIL_PATTERN)) { + domainSet.add(match[0].split('@')[1].toLowerCase()) + } + for (const match of text.matchAll(ONMICROSOFT_PATTERN)) { + domainSet.add(match[0].toLowerCase()) + } + const tenant = bundle?.client?.tenant + if (tenant && tenant !== 'AllTenants' && tenant.includes('.')) { + domainSet.add(tenant.toLowerCase()) + } + + text = text.replace(EMAIL_PATTERN, (value) => { + const key = value.toLowerCase() + if (!emailMap.has(key)) + emailMap.set(key, `user${emailMap.size + 1}@redacted.invalid`) + return emailMap.get(key) + }) + + for (const domain of domainSet) { + if (!domainMap.has(domain)) + domainMap.set(domain, `domain${domainMap.size + 1}.invalid`) + text = text.replace( + new RegExp(escapeRegExp(domain), 'gi'), + domainMap.get(domain) + ) + } + + text = text.replace(GUID_PATTERN, (value) => { + const key = value.toLowerCase() + if (!guidMap.has(key)) { + guidMap.set( + key, + `00000000-0000-0000-0000-${String(guidMap.size + 1).padStart(12, '0')}` + ) + } + return guidMap.get(key) + }) + + for (const [token, host] of keepTokens) { + text = text.replaceAll(token, host) + } + + return { + bundle: JSON.parse(text), + summary: { + emails: emailMap.size, + domains: domainMap.size, + guids: guidMap.size, + }, + } +} + +export const downloadSupportBundle = (bundle) => { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) + const filename = `cipp-support-bundle_${window.location.hostname}_${timestamp}.json` + const blob = new Blob([JSON.stringify(bundle, null, 2)], { + type: 'application/json', + }) + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + document.body.appendChild(anchor) + anchor.click() + document.body.removeChild(anchor) + URL.revokeObjectURL(url) + return filename +} diff --git a/tests/components/CippCards/CippPageCard.test.jsx b/tests/components/CippCards/CippPageCard.test.jsx index b19ebde03541..0a4933779dcb 100644 --- a/tests/components/CippCards/CippPageCard.test.jsx +++ b/tests/components/CippCards/CippPageCard.test.jsx @@ -1,35 +1,90 @@ -import React from 'react' -import { screen } from '@testing-library/react' -import { renderWithProviders } from '../../test-utils' -import CippPageCard from '../../../src/components/CippCards/CippPageCard' - -describe('CippPageCard', () => { - it('renders title and children', () => { - renderWithProviders( - -
    Child content
    -
    - ) - expect(screen.getByText('Test Page Title')).toBeInTheDocument() - expect(screen.getByText('Child content')).toBeInTheDocument() - }) - - it('does not render heading when hideTitleText is true', () => { - renderWithProviders( - -
    Child content
    -
    - ) - expect(screen.queryByRole('heading', { name: 'Hidden Title' })).not.toBeInTheDocument() - expect(screen.queryByText('Hidden Title')).not.toBeInTheDocument() - }) - - it('renders infoBar when provided', () => { - renderWithProviders( - Info bar content
    }> -
    Child content
    +import React from "react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import { renderWithProviders } from "../../test-utils"; + +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ isMobile: false })); +vi.mock("../../../src/hooks/use-breakpoint", async (importOriginal) => ({ + ...(await importOriginal()), + useIsMobileLayout: () => layoutState.isMobile, + useIsTabletLayout: () => false, +})); + +const routerState = vi.hoisted(() => ({ push: vi.fn(), pathname: "/cipp/roles" })); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: routerState.push }), + usePathname: () => routerState.pathname, + useSearchParams: () => new URLSearchParams(""), +})); +vi.mock("next/router", () => ({ + useRouter: () => ({ push: routerState.push, back: vi.fn() }), +})); + +// Stable identities: a fresh object per call re-renders forever (tests/mocks/api-call.js) +const idle = vi.hoisted(() => ({ + isSuccess: false, + isFetching: false, + isPending: false, + isError: false, + data: undefined, + mutate: () => {}, + reset: () => {}, + refetch: () => {}, +})); +vi.mock("../../../src/api/ApiCall", () => ({ + ApiGetCall: () => idle, + ApiPostCall: () => idle, + ApiGetCallWithPagination: () => ({ ...idle, fetchNextPage: () => {} }), +})); + +import { TabbedLayout } from "../../../src/layouts/TabbedLayout"; +import CippPageCard from "../../../src/components/CippCards/CippPageCard"; + +const tabOptions = [ + { label: "CIPP Roles", path: "/cipp/roles" }, + { label: "CIPP Users", path: "/cipp/users" }, +]; + +const renderPage = (title) => + renderWithProviders( + + +
    page content
    - ) - expect(screen.getByText('Info bar content')).toBeInTheDocument() - }) -}) +
    + ); + +describe("CippPageCard title vs the mobile tab picker", () => { + beforeEach(() => { + layoutState.isMobile = false; + routerState.pathname = "/cipp/roles"; + }); + + // The picker trigger wears the current tab's label in heading clothes right above the + // page header — a page titled the same printed "CIPP Roles" twice in a row on a phone. + it("stands its title down when the picker already says it", () => { + layoutState.isMobile = true; + renderPage("CIPP Roles"); + + // once: the picker trigger (whose label is itself an h6 — query the page h4 by level) + expect(screen.getAllByText("CIPP Roles")).toHaveLength(1); + expect(screen.getByRole("button", { name: /CIPP Roles switch view/i })).toBeInTheDocument(); + expect(screen.queryByRole("heading", { level: 4, name: "CIPP Roles" })).not.toBeInTheDocument(); + }); + + it("keeps a title the picker does not carry", () => { + layoutState.isMobile = true; + renderPage("Edit Role: limited"); + + expect( + screen.getByRole("heading", { level: 4, name: "Edit Role: limited" }) + ).toBeInTheDocument(); + }); + + it("keeps its title on desktop, where tabs look like navigation", () => { + renderPage("CIPP Roles"); + + expect(screen.getByRole("heading", { level: 4, name: "CIPP Roles" })).toBeInTheDocument(); + }); +}); diff --git a/tests/components/CippCards/CippUniversalSearchV2.stories.jsx b/tests/components/CippCards/CippUniversalSearchV2.stories.jsx new file mode 100644 index 000000000000..3111dfff96be --- /dev/null +++ b/tests/components/CippCards/CippUniversalSearchV2.stories.jsx @@ -0,0 +1,62 @@ +import React from 'react' +import { within, waitFor, expect } from 'storybook/test' +import { CippUniversalSearchV2 } from '../../../src/components/CippCards/CippUniversalSearchV2' +import { shrinkToPhoneViewport, growToDesktopViewport } from '../../viewport' + +export default { + title: 'Components/CippCards/CippUniversalSearchV2', + component: CippUniversalSearchV2, + tags: ['autodocs'], +} + +// Desktop: the scope button, field and search button are one joined bordered control. The +// theme defaults TextField to the filled variant, whose own rounded border ignores every +// join rule (they target .MuiOutlinedInput-root) — which once rendered the scope button and +// field as two separate boxes. +export const JoinedControlOnDesktop = { + render: () => ( + + ), + play: async ({ canvasElement, step }) => { + const onDesktop = await growToDesktopViewport() + if (!onDesktop) return + const canvas = within(canvasElement) + + await step('the scope button and the field share one border, no gap', async () => { + const scope = canvas.getByRole('button', { name: /pages/i }) + const field = canvasElement.querySelector('.MuiOutlinedInput-root') + await waitFor(() => { + expect(field).not.toBeNull() + const gap = field.getBoundingClientRect().left - scope.getBoundingClientRect().right + expect(Math.abs(gap)).toBeLessThanOrEqual(1) + expect(getComputedStyle(field).borderTopLeftRadius).toBe('0px') + expect(getComputedStyle(scope).borderTopRightRadius).toBe('0px') + }) + }) + }, +} + +// Phones: no scope button in the group — the field spans the row and each scope is a chip, +// one tap away, so entity search has a direct entry point. +export const ScopeChipsAtPhoneWidth = { + render: () => ( + + ), + play: async ({ canvasElement, step }) => { + const onAPhone = await shrinkToPhoneViewport() + if (!onAPhone) return + const canvas = within(canvasElement) + + await step('the field takes the full row and every scope is a visible chip', async () => { + const field = canvasElement.querySelector('.MuiOutlinedInput-root') + await waitFor(() => { + expect(field).not.toBeNull() + for (const label of ['Users', 'Groups', 'Applications', 'Licenses', 'BitLocker', 'Pages']) { + expect(canvas.getByText(label)).toBeInTheDocument() + } + }) + const host = canvasElement + expect(host.scrollWidth).toBeLessThanOrEqual(host.clientWidth) + }) + }, +} diff --git a/tests/components/CippCards/CippUniversalSearchV2.test.jsx b/tests/components/CippCards/CippUniversalSearchV2.test.jsx new file mode 100644 index 000000000000..2f6cdc92f3b6 --- /dev/null +++ b/tests/components/CippCards/CippUniversalSearchV2.test.jsx @@ -0,0 +1,125 @@ +import React from 'react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithProviders } from '../../test-utils' + +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ isMobile: false })) +vi.mock('../../../src/hooks/use-breakpoint', async (importOriginal) => ({ + ...(await importOriginal()), + useIsMobileLayout: () => layoutState.isMobile, +})) + +const bookmarkState = vi.hoisted(() => ({ bookmarks: [] })) +vi.mock('../../../src/hooks/use-user-bookmarks', () => ({ + useUserBookmarks: () => ({ bookmarks: bookmarkState.bookmarks, setBookmarks: () => {} }), +})) + +const idle = vi.hoisted(() => ({ + isSuccess: false, + isFetching: false, + isLoading: false, + isError: false, + data: undefined, + refetch: () => {}, +})) +vi.mock('../../../src/api/ApiCall', () => ({ + ApiGetCall: () => idle, + ApiPostCall: () => idle, + ApiGetCallWithPagination: () => ({ ...idle, fetchNextPage: () => {} }), +})) + +const routerState = vi.hoisted(() => ({ push: vi.fn() })) +vi.mock('next/router', () => ({ + useRouter: () => ({ + pathname: '/', + query: {}, + isReady: true, + push: routerState.push, + events: { on: () => {}, off: () => {} }, + }), +})) + +vi.mock('../../../src/hooks/use-permissions', () => ({ + // the page index filters by permission; 'Identity.User.Read' satisfies the config's + // 'Identity.User.*' requirement so the Users pages exist to be found + usePermissions: () => ({ userPermissions: ['Identity.User.Read'], userRoles: ['superadmin'] }), +})) + +import { CippUniversalSearchV2 } from '../../../src/components/CippCards/CippUniversalSearchV2' + +describe('CippUniversalSearchV2 mobile layout', () => { + beforeEach(() => { + layoutState.isMobile = false + bookmarkState.bookmarks = [] + routerState.push = vi.fn() + }) + + it('keeps the scope dropdown on desktop, no chips', () => { + renderWithProviders() + expect(screen.getByRole('button', { name: /pages/i })).toBeInTheDocument() + expect(screen.queryByText('Users', { selector: '.MuiChip-label' })).not.toBeInTheDocument() + }) + + // The desktop scope dropdown cost two taps, and entity search had no direct mobile entry + // point at all — one chip per scope closes that. + it('renders one chip per scope on mobile and switches with a tap', async () => { + layoutState.isMobile = true + const user = userEvent.setup() + renderWithProviders() + + for (const label of ['Users', 'Groups', 'Applications', 'Licenses', 'BitLocker', 'Pages']) { + expect(screen.getByText(label, { selector: '.MuiChip-label' })).toBeInTheDocument() + } + + await user.click(screen.getByText('Users', { selector: '.MuiChip-label' })) + expect(screen.getByPlaceholderText(/search users/i)).toBeInTheDocument() + + // BitLocker reveals its lookup sub-choice as a second chip row + await user.click(screen.getByText('BitLocker', { selector: '.MuiChip-label' })) + expect(screen.getByText('Key ID', { selector: '.MuiChip-label' })).toBeInTheDocument() + expect(screen.getByText('Device ID', { selector: '.MuiChip-label' })).toBeInTheDocument() + }) + + it('fills the empty state with bookmarks that navigate and close', async () => { + layoutState.isMobile = true + bookmarkState.bookmarks = [ + { label: 'GDAP Relationships', path: '/tenant/gdap-management/relationships', category: 'Tenant' }, + ] + const onConfirm = vi.fn() + const user = userEvent.setup() + renderWithProviders() + + expect(screen.getByText('Bookmarks')).toBeInTheDocument() + await user.click(screen.getByText('GDAP Relationships')) + expect(routerState.push).toHaveBeenCalledWith('/tenant/gdap-management/relationships') + expect(onConfirm).toHaveBeenCalled() + }) + + // userEvent.click fires mousedown -> click; the outside-click closer ran on mousedown, + // unmounted the row, and the click landed on nothing — results vanished, no navigation. + it('navigates when a page result is tapped, instead of just closing', async () => { + layoutState.isMobile = true + const onConfirm = vi.fn() + const user = userEvent.setup() + renderWithProviders() + + await user.type(screen.getByPlaceholderText(/search pages/i), 'users') + const result = await screen.findAllByRole('menuitem') + await user.click(result[0]) + + expect(routerState.push).toHaveBeenCalled() + expect(onConfirm).toHaveBeenCalled() + }) + + it('renders page results in flow on mobile, not in a portal panel', async () => { + layoutState.isMobile = true + const user = userEvent.setup() + renderWithProviders() + + await user.type(screen.getByPlaceholderText(/search pages/i), 'users') + // the floating panel marks itself; in-flow results must not + expect(document.querySelector('[data-dropdown-portal]')).toBeNull() + }) +}) diff --git a/tests/components/CippCards/mobile-overflow.stories.jsx b/tests/components/CippCards/mobile-overflow.stories.jsx new file mode 100644 index 000000000000..4809afc86ebb --- /dev/null +++ b/tests/components/CippCards/mobile-overflow.stories.jsx @@ -0,0 +1,167 @@ +import React, { useRef, useState, useEffect } from 'react' +import { Box, Card } from '@mui/material' +import { within, waitFor, expect } from 'storybook/test' +import { CippChartCard } from '../../../src/components/CippCards/CippChartCard' +import { CippImageCard } from '../../../src/components/CippCards/CippImageCard' +import { CippVariableAutocomplete } from '../../../src/components/CippComponents/CippVariableAutocomplete' +import { PermissionTable } from '../../../src/components/CippSettings/CippSSOSettings' +import { shrinkToPhoneViewport } from '../../viewport' + +/** + * Phone-width overflow checks for the shared components the mobile audit found spilling out + * of the viewport. Each story renders the component with the hostile content class that + * broke it — API free text, fixed-width caps — and asserts the page body gained no sideways + * scroll at 390px. + */ +export default { + title: 'Components/MobileOverflow', + tags: ['autodocs'], +} + +const noBodyOverflow = () => { + const doc = document.documentElement + expect(doc.scrollWidth).toBeLessThanOrEqual(doc.clientWidth) +} + +// Legend labels are API free text — recipient addresses, SharePoint library URLs. Without +// minWidth: 0 flexbox refuses to shrink them and the rows push out of the card. +export const ChartLegendWithUrlLabels = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const onAPhone = await shrinkToPhoneViewport() + if (!onAPhone) return + const canvas = within(canvasElement) + const label = await canvas.findByText(/finance/, { exact: false }) + await waitFor(() => { + // the count is the row's right-hand cell: an unshrinkable label pushed it past the + // card's clipped edge, where MUI's overflow: hidden ate it without a trace + const card = label.closest('.MuiCard-root') + const count = canvas.getByText('12') + expect(count.getBoundingClientRect().right).toBeLessThanOrEqual( + card.getBoundingClientRect().right + 1 + ) + noBodyOverflow() + }) + }, +} + +// The headline/illustration pair had no breakpoint and no minWidth: 0 — at 390px the text +// column collapsed against the image's intrinsic width. This is the AllTenants interstitial. +export const ImageCardAtPhoneWidth = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const onAPhone = await shrinkToPhoneViewport() + if (!onAPhone) return + const canvas = within(canvasElement) + const title = await canvas.findByText(/does not support/, { exact: false }) + await waitFor(() => { + // stacked, not squeezed: the old row layout let flexbox settle the fight by + // collapsing the illustration to zero width — "no overflow" while showing nothing + const img = canvasElement.querySelector('img') + const imgBox = img.getBoundingClientRect() + expect(imgBox.top).toBeGreaterThanOrEqual(title.getBoundingClientRect().bottom) + expect(imgBox.width).toBeGreaterThanOrEqual(200) + noBodyOverflow() + }) + }, +} + +const LONG_DESCRIPTION = + 'The primary tenant domain name used for routing and identification across all portals, ' + + 'reports and scheduled tasks — substituted at execution time from the tenant record.' + +const PopperHost = () => { + const anchorRef = useRef(null) + const [anchorEl, setAnchorEl] = useState(null) + useEffect(() => setAnchorEl(anchorRef.current), []) + return ( + +
    + {anchorEl && ( + {}} + onSelect={() => {}} + customVariables={[ + { variable: 'tenantfilter', description: LONG_DESCRIPTION }, + { variable: 'defaultdomainname', description: LONG_DESCRIPTION }, + ]} + /> + )} + + ) +} + +// Sentinel, not a repro: in this browser the absolutely-positioned Paper shrink-to-fits +// inside the viewport even pre-fix, so this story also passed before the clamp. It stands +// guard against a future fixed `width` here. The popper is portaled, so the assertion +// measures against the viewport, not the canvas. +export const VariablePopperStaysOnScreen = { + render: () => , + play: async () => { + const onAPhone = await shrinkToPhoneViewport() + if (!onAPhone) return + await waitFor(() => { + const paper = document.querySelector('[data-cipp-autocomplete="true"]') + expect(paper).not.toBeNull() + const { right, left } = paper.getBoundingClientRect() + expect(left).toBeGreaterThanOrEqual(0) + expect(right).toBeLessThanOrEqual(document.documentElement.clientWidth) + }) + noBodyOverflow() + }, +} + +// Sentinel: in this browser the longest name happens to fit a full-width card even without +// the fix (the audited clip came from the settings page's narrower column and other font +// metrics). Guards the invariant that matters — the permission being consented to is +// readable inside the card, whatever this table is later wrapped in. +export const SsoPermissionTableReadable = { + render: () => ( + + + + ), + play: async ({ canvasElement }) => { + const onAPhone = await shrinkToPhoneViewport() + if (!onAPhone) return + const canvas = within(canvasElement) + const name = await canvas.findByText(/ApplicationConfiguration/, { exact: false }) + await waitFor(() => { + // reachable: the name's box ends inside the card, not under its clipped edge + const card = name.closest('.MuiCard-root') + expect(name.getBoundingClientRect().right).toBeLessThanOrEqual( + card.getBoundingClientRect().right + 1 + ) + noBodyOverflow() + }) + }, +} diff --git a/tests/components/CippComponents/CippAddUserDrawer.test.jsx b/tests/components/CippComponents/CippAddUserDrawer.test.jsx new file mode 100644 index 000000000000..f77592a792cd --- /dev/null +++ b/tests/components/CippComponents/CippAddUserDrawer.test.jsx @@ -0,0 +1,197 @@ +import React, { useReducer } from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithProviders, settingsWith } from '../../test-utils' +import { CippAddUserDrawer } from '../../../src/components/CippComponents/CippAddUserDrawer' +import { ApiGetCall, ApiPostCall, ApiGetCallWithPagination } from '../../../src/api/ApiCall' + +vi.mock('../../../src/api/ApiCall', () => ({ + ApiGetCall: vi.fn(), + ApiPostCall: vi.fn(), + ApiGetCallWithPagination: vi.fn(), +})) + +// The user pickers and the license selector pull in the data-table stack and the 2.2 MB license +// dataset; none of them take part in the create-another-user flow, so they are stubbed. The +// domain selector stays real - its auto-preselect is central to the bug under test. +vi.mock('../../../src/components/CippComponents/CippFormUserSelector', () => ({ + CippFormUserSelector: () =>
    , + default: () =>
    , +})) +vi.mock('../../../src/components/CippComponents/CippFormLicenseSelector', () => ({ + CippFormLicenseSelector: () =>
    , + default: () =>
    , +})) +vi.mock('../../../src/components/CippComponents/CippApiResults', () => ({ + CippApiResults: () => null, +})) +// CippFormComponent statically imports the data-table stack for its cippDataTable case; +// nothing in this drawer uses it, but importing it is enough to exhaust the test worker. +vi.mock('../../../src/components/CippTable/CippDataTable', () => ({ + CippDataTable: () =>
    , + default: () =>
    , +})) +// CippAutoComplete statically imports CippJsonView for its option-preview offcanvas, which +// drags in the formatting/code-block/Intune-definition graph - another worker-killer this +// flow never renders. +vi.mock('../../../src/components/CippFormPages/CippJSONView', () => ({ + default: () => null, +})) +// The real drawer shell drags in the property-card/formatting graph, which this test does +// not exercise. The stub keeps the essential contract: content + footer render only while +// the drawer is open. +vi.mock('../../../src/components/CippComponents/CippOffCanvas', () => ({ + CippOffCanvas: ({ visible, children, footer }) => + visible ? ( +
    + {children} + {footer} +
    + ) : null, +})) + +const idleGet = { isSuccess: false, isFetching: false, isError: false, data: undefined, refetch: vi.fn() } +const okGet = (data) => ({ isSuccess: true, isFetching: false, isError: false, data, refetch: vi.fn() }) + +// Mutable state backing the ApiPostCall mock: flipping it and re-rendering imitates the +// react-query mutation lifecycle (idle -> pending -> success) the drawer sees in production. +let postState +let mutateSpy + +function mockApis() { + ApiGetCall.mockImplementation(({ url }) => { + if (url.startsWith('/api/ListNewUserDefaults')) return okGet([]) + if (url.startsWith('/api/ListExtensionsConfig')) return okGet({}) + if (url.startsWith('/api/ListGroups')) return okGet([]) + if (url.startsWith('/api/ListCustomDataMappings')) return okGet({ Results: [] }) + if (url.startsWith('/api/ListUserGroups')) return okGet([]) + return idleGet + }) + ApiGetCallWithPagination.mockImplementation(({ url }) => { + if (url === '/api/ListGraphRequest') { + return { + isSuccess: true, + isFetching: false, + isError: false, + data: { + pages: [ + { + Results: [ + { id: 'testdomain.com', isDefault: true, isInitial: false, isVerified: true }, + { id: 'other.com', isDefault: false, isInitial: false, isVerified: true }, + ], + }, + ], + }, + fetchNextPage: vi.fn(), + refetch: vi.fn(), + } + } + return { ...idleGet, fetchNextPage: vi.fn() } + }) + ApiPostCall.mockImplementation(() => ({ ...postState, mutate: mutateSpy })) +} + +// Buttons that force a re-render after mutating postState stand in for react-query pushing new +// mutation state into the drawer. +function Harness() { + const [, force] = useReducer((x) => x + 1, 0) + return ( + <> + + + + + ) +} + +const getDomainInput = () => + screen.getByLabelText(/Primary Domain name/i, { selector: 'input' }) + +const fillRequiredFields = async (user, { displayName, username }) => { + const displayNameInput = screen.getByLabelText(/Display Name/i, { selector: 'input' }) + await user.clear(displayNameInput) + await user.type(displayNameInput, displayName) + const usernameInput = screen.getByLabelText(/^Username/i, { selector: 'input' }) + await user.clear(usernameInput) + await user.type(usernameInput, username) +} + +describe('CippAddUserDrawer - create another user without a page refresh (issue #309)', () => { + beforeEach(() => { + vi.clearAllMocks() + postState = { isPending: false, isSuccess: false, isError: false } + mutateSpy = vi.fn() + mockApis() + }) + + it('re-enables the Create button for a second user after the first succeeds', async () => { + const user = userEvent.setup() + renderWithProviders(, { + settings: settingsWith({ usageLocation: { value: 'US', label: 'United States' } }), + }) + + await user.click(screen.getByRole('button', { name: 'Add User' })) + + // First user: the domain selector auto-picks the tenant default domain + await waitFor(() => { + expect(getDomainInput()).toHaveValue('testdomain.com') + }) + await fillRequiredFields(user, { displayName: 'First User', username: 'first.user' }) + + const createButton = screen.getByRole('button', { name: 'Create User' }) + await waitFor(() => { + expect(createButton).toBeEnabled() + }) + await user.click(createButton) + expect(mutateSpy).toHaveBeenCalledTimes(1) + expect(mutateSpy.mock.calls[0][0].data).toMatchObject({ + displayName: 'First User', + username: 'first.user', + primDomain: { value: 'testdomain.com' }, + }) + + // Simulate the mutation lifecycle so isSuccess transitions like it does in production + await user.click(screen.getByRole('button', { name: 'flip-pending' })) + await user.click(screen.getByRole('button', { name: 'flip-success' })) + + // The drawer resets the form for the next user + const anotherButton = await screen.findByRole('button', { name: 'Create Another User' }) + + // The remounted domain selector must auto-pick the default domain again; without it the + // required primDomain stays silently empty and the button never re-enables (issue #309) + await waitFor(() => { + expect(getDomainInput()).toHaveValue('testdomain.com') + }) + + // Second user: complete all required fields again, exactly as the issue describes + await fillRequiredFields(user, { displayName: 'Second User', username: 'second.user' }) + + await waitFor(() => { + expect(anotherButton).toBeEnabled() + }) + await user.click(anotherButton) + expect(mutateSpy).toHaveBeenCalledTimes(2) + expect(mutateSpy.mock.calls[1][0].data).toMatchObject({ + displayName: 'Second User', + username: 'second.user', + primDomain: { value: 'testdomain.com' }, + }) + }) +}) diff --git a/tests/components/CippComponents/CippApiDialog.test.jsx b/tests/components/CippComponents/CippApiDialog.test.jsx index a1c94bfc7358..032d9d8f5a23 100644 --- a/tests/components/CippComponents/CippApiDialog.test.jsx +++ b/tests/components/CippComponents/CippApiDialog.test.jsx @@ -116,4 +116,38 @@ describe('CippApiDialog', () => { await user.click(screen.getByRole('button', { name: 'Close' })) expect(createDialog.handleClose).toHaveBeenCalledTimes(1) }) + + it('resolves dotted parent maps on confirm', async () => { + const user = userEvent.setup() + renderDialog({ + row: { + id: 'member-1', + displayName: 'Jane', + parent: { id: 'group-1', displayName: 'Finance' }, + }, + api: { + type: 'POST', + url: '/api/ExecWhatever', + data: { childId: 'id', parentId: 'parent.id' }, + confirmText: 'Remove [displayName] from [parent.displayName]?', + }, + }) + + expect(screen.getByText('Remove Jane from Finance?')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Confirm' })) + + await waitFor(() => { + expect(apiState.mutate).toHaveBeenCalledTimes(1) + }) + expect(apiState.mutate).toHaveBeenCalledWith({ + url: '/api/ExecWhatever', + bulkRequest: false, + data: { + tenantFilter: 'testdomain.com', + childId: 'member-1', + parentId: 'group-1', + }, + }) + }) }) diff --git a/tests/components/CippComponents/CippAppPermissionBuilder.stories.jsx b/tests/components/CippComponents/CippAppPermissionBuilder.stories.jsx new file mode 100644 index 000000000000..1fff3b9167d2 --- /dev/null +++ b/tests/components/CippComponents/CippAppPermissionBuilder.stories.jsx @@ -0,0 +1,93 @@ +import React from 'react' +import { http, HttpResponse } from 'msw' +import { within, expect, waitFor } from 'storybook/test' +import { Box } from '@mui/material' +import { useForm } from 'react-hook-form' +import { shrinkToPhoneViewport } from '../../viewport' +import CippAppPermissionBuilder from '../../../src/components/CippComponents/CippAppPermissionBuilder' + +// The summary row carries a 36-character app id, so this is where the overflow shows up. +const graph = { + id: 'sp-graph', + appId: '00000003-0000-0000-c000-000000000000', + displayName: 'Microsoft Graph', + appRoles: [], + publishedPermissionScopes: [], +} + +const servicePrincipals = { Metadata: { Success: true }, Results: [graph] } + +// The same route serves the list and, with ?Id=, one principal's detail — where Results is +// an object rather than an array. +const handlers = [ + http.get('*/api/ExecServicePrincipals', ({ request }) => { + const id = new URL(request.url).searchParams.get('Id') + return HttpResponse.json( + id ? { Metadata: { Success: true }, Results: graph } : servicePrincipals + ) + }), +] + +const Harness = (props) => { + const formControl = useForm({ mode: 'onChange', defaultValues: { servicePrincipal: null } }) + return ( + {}} + updatePermissions={{ isPending: false, isSuccess: false, isError: false }} + currentPermissions={{ + Permissions: { + '00000003-0000-0000-c000-000000000000': { + applicationPermissions: [{ id: '1', value: 'Application.ReadWrite.All' }], + delegatedPermissions: [{ id: '2', value: 'User.Read' }], + }, + }, + }} + {...props} + /> + ) +} + +export default { + title: 'Components/CippComponents/CippAppPermissionBuilder', + component: CippAppPermissionBuilder, + parameters: { msw: { handlers } }, +} + +// jsdom has no layout engine, so overflow is invisible to the unit tests — this is the one +// place a real browser can measure it. 390px is an iPhone 14/15 in portrait. +// +// The VIEWPORT has to shrink, not a wrapper: MUI's breakpoints are media queries, so a +// 390px-wide Box inside a desktop-width iframe still renders every `md` branch. +export const PhoneWidth = { + render: () => ( + + + + ), + play: async ({ canvasElement }) => { + const onAPhone = await shrinkToPhoneViewport() + const canvas = within(canvasElement) + await canvas.findByText('Microsoft Graph', {}, { timeout: 10000 }) + // Opened in the Storybook app rather than the test runner: the layout is on show, but + // measuring it against a desktop-width iframe would only assert the wrong thing. + if (!onAPhone) return + + // The app-id chip used to force the row wider than the phone, pushing the service + // principal's name off the left edge — the row scrolled, the name was unreachable. + await waitFor(() => { + const rows = canvasElement.querySelectorAll('.MuiAccordionSummary-root') + expect(rows.length).toBeGreaterThan(0) + rows.forEach((row) => { + expect(row.scrollWidth).toBeLessThanOrEqual(row.clientWidth) + }) + }) + + // and the name is inside the viewport, not off to the left of it + const name = canvas.getByText('Microsoft Graph') + const phone = canvasElement.querySelector('[data-testid="phone"]') + expect(name.getBoundingClientRect().left).toBeGreaterThanOrEqual( + phone.getBoundingClientRect().left + ) + }, +} diff --git a/tests/components/CippComponents/CippAutocomplete.test.jsx b/tests/components/CippComponents/CippAutocomplete.test.jsx index e5a47bc5845a..90dbd2aa6a36 100644 --- a/tests/components/CippComponents/CippAutocomplete.test.jsx +++ b/tests/components/CippComponents/CippAutocomplete.test.jsx @@ -345,4 +345,51 @@ describe('CippAutoComplete', () => { expect(document.querySelector('.MuiFormLabel-asterisk')).toBeTruthy() }) }) + + // TextField forwards what it doesn't consume to the FormControl root, so a leak lands as a DOM attr + describe('prop routing', () => { + it('keeps autocomplete-only props off the DOM', () => { + const { container } = renderWithProviders( + {}} + noOptionsText="nothing here" + /> + ) + expect(container.querySelector('[nooptionstext]')).toBeNull() + }) + + it('routes variant to the text field, not to the autocomplete root', () => { + const { container } = renderWithProviders( + {}} + variant="outlined" + /> + ) + // outlined draws the notched fieldset/legend, the themed filled default does not + expect(container.querySelector('fieldset legend')).toBeTruthy() + expect(container.querySelector('[variant]')).toBeNull() + }) + + it('forwards filterSelectedOptions to the autocomplete, selected option stays listed', async () => { + const user = userEvent.setup() + renderWithProviders( + {}} + filterSelectedOptions={false} + /> + ) + await user.click(screen.getByRole('combobox')) + expect(await screen.findByRole('option', { name: 'Alpha' })).toBeInTheDocument() + }) + }) }) diff --git a/tests/components/CippComponents/CippAutopilotProfileDrawer.test.jsx b/tests/components/CippComponents/CippAutopilotProfileDrawer.test.jsx new file mode 100644 index 000000000000..6dbb03a2797b --- /dev/null +++ b/tests/components/CippComponents/CippAutopilotProfileDrawer.test.jsx @@ -0,0 +1,163 @@ +import React from 'react' +import { act, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithProviders } from '../../test-utils' +import { api, apiCallMock, getResult } from '../../mocks/api-call' +import { CippAutopilotProfileDrawer } from '../../../src/components/CippComponents/CippAutopilotProfileDrawer' + +// The tenant selector talks to Graph; the autopilot drawer only needs it to drive +// `selectedTenants` on the form so the single-tenant gate around the group picker works. +const tenants = vi.hoisted(() => ({ value: [] })) +const tenantForm = vi.hoisted(() => ({ current: null })) +vi.mock( + '../../../src/components/CippComponents/CippFormTenantSelector', + async () => { + const React = await import('react') + return { + CippFormTenantSelector: ({ formControl, name = 'selectedTenants' }) => { + tenantForm.current = formControl + React.useEffect(() => { + formControl.setValue(name, tenants.value, { + shouldValidate: true, + shouldDirty: true, + }) + }, [formControl, name]) + return null + }, + } + } +) + +vi.mock('../../../src/api/ApiCall', () => apiCallMock()) + +const singleTenant = [{ value: 'contoso.com', label: 'Contoso' }] +const multiTenant = [ + { value: 'contoso.com', label: 'Contoso' }, + { value: 'fabrikam.com', label: 'Fabrikam' }, +] +const groupsResult = getResult({ data: [] }) +const authWithGroupRead = getResult({ + data: { + clientPrincipal: { userRoles: ['custom'] }, + permissions: ['Endpoint.Autopilot.ReadWrite', 'Identity.Group.Read'], + }, +}) +const authWithoutGroupRead = getResult({ + data: { + clientPrincipal: { userRoles: ['custom'] }, + permissions: ['Endpoint.Autopilot.ReadWrite'], + }, +}) + +async function openDrawer() { + const user = userEvent.setup() + renderWithProviders() + await user.click(screen.getByRole('button', { name: 'Add Profile' })) + return user +} + +describe('CippAutopilotProfileDrawer', () => { + beforeEach(() => { + tenants.value = singleTenant + tenantForm.current = null + api.get = (options) => + options.url === '/api/me' ? authWithGroupRead : groupsResult + api.post = { ...api.post, mutate: vi.fn() } + }) + + it('shows no group UI while "Assign to all devices" is on (default)', async () => { + await openDrawer() + expect( + screen.queryByText('Assign to Selected Groups') + ).not.toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('warns instead of group-picking when more than one tenant is selected', async () => { + tenants.value = multiTenant + const user = await openDrawer() + await user.click(screen.getByLabelText('Assign to all devices')) + expect( + screen.getByText(/profiling by group requires selecting a single tenant/i) + ).toBeInTheDocument() + expect( + screen.queryByLabelText('Assign to Selected Groups') + ).not.toBeInTheDocument() + }) + + it('shows the group picker when groups are off and exactly one tenant is selected', async () => { + const user = await openDrawer() + await user.click(screen.getByLabelText('Assign to all devices')) + expect( + screen.queryByText(/profiling by group requires/i) + ).not.toBeInTheDocument() + expect( + screen.getByRole('combobox', { name: 'Assign to Selected Groups' }) + ).toBeInTheDocument() + }) + + it('does not load the group picker without Identity Group Read permission', async () => { + api.get = (options) => + options.url === '/api/me' ? authWithoutGroupRead : groupsResult + + const user = await openDrawer() + await user.click(screen.getByLabelText('Assign to all devices')) + + expect( + screen.getByText(/requires the Identity Group Read permission/i) + ).toBeInTheDocument() + expect( + screen.queryByLabelText('Assign to Selected Groups') + ).not.toBeInTheDocument() + }) + + it('drops selected groups when the tenant changes before submission', async () => { + const user = await openDrawer() + await user.click(screen.getByLabelText('Assign to all devices')) + + act(() => { + tenantForm.current.setValue('GroupIds', [ + { value: 'group-1', label: 'Group 1' }, + ]) + tenantForm.current.setValue('selectedTenants', [ + { value: 'fabrikam.com', label: 'Fabrikam' }, + ]) + }) + + await user.type( + screen.getByRole('textbox', { name: 'Display Name' }), + 'Test AP' + ) + const submit = screen.getByRole('button', { name: 'Create Profile' }) + await waitFor(() => expect(submit).toBeEnabled()) + await user.click(submit) + + await waitFor(() => expect(api.post.mutate).toHaveBeenCalledTimes(1)) + expect(api.post.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ GroupIds: [] }), + }) + ) + }) + + it('submits to AddAutopilotConfig with group ids as an array', async () => { + const user = await openDrawer() + await user.type( + screen.getByRole('textbox', { name: 'Display Name' }), + 'Test AP' + ) + const submit = screen.getByRole('button', { name: 'Create Profile' }) + await waitFor(() => expect(submit).toBeEnabled()) + await user.click(submit) + + await waitFor(() => { + expect(api.post.mutate).toHaveBeenCalledTimes(1) + }) + expect(api.post.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + url: '/api/AddAutopilotConfig', + data: expect.objectContaining({ DisplayName: 'Test AP', GroupIds: [] }), + }) + ) + }) +}) diff --git a/tests/components/CippComponents/CippBottomSheet.stories.jsx b/tests/components/CippComponents/CippBottomSheet.stories.jsx new file mode 100644 index 000000000000..71e9268c2b0c --- /dev/null +++ b/tests/components/CippComponents/CippBottomSheet.stories.jsx @@ -0,0 +1,207 @@ +import React from 'react' +import { within, expect, userEvent, waitFor } from 'storybook/test' +import { + Button, + Dialog, + DialogContent, + List, + ListItemButton, + ListItemText, + Typography, +} from '@mui/material' +import { CippBottomSheet } from '../../../src/components/CippComponents/CippBottomSheet' +import { shrinkToPhoneViewport } from '../../viewport' + +// The mobile stand-in for a desktop Menu: every place the app opens a Menu on a pointer +// device opens one of these below md instead. +const SheetHarness = ({ children, triggerLabel = 'Open sheet', ...sheetProps }) => { + const [open, setOpen] = React.useState(false) + return ( + <> + + setOpen(false)} {...sheetProps}> + {children} + + + ) +} + +const actionRows = ['Edit user', 'Reset password', 'Block sign-in'].map((label) => ( + + + +)) + +export default { + title: 'Components/CippComponents/CippBottomSheet', + component: CippBottomSheet, + tags: ['autodocs'], +} + +export const WithTitle = { + render: () => ( + + {actionRows} + + ), + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + const body = within(document.body) + + await step('opens on tap and shows its rows', async () => { + await userEvent.click(canvas.getByRole('button', { name: 'Open sheet' })) + await waitFor(() => expect(body.getByText('Row actions')).toBeInTheDocument()) + expect(body.getByText('Reset password')).toBeInTheDocument() + }) + + await step('closes on backdrop tap', async () => { + await userEvent.click(document.querySelector('.MuiBackdrop-root')) + await waitFor(() => expect(body.queryByText('Row actions')).not.toBeInTheDocument()) + }) + }, +} + +export const WithFooter = { + render: () => ( + + Apply to 12 selected + + } + > + {actionRows} + + ), +} + +export const LongContentScrolls = { + render: () => ( + + + {Array.from({ length: 30 }, (_, i) => ( + + + + ))} + + + ), +} + +// Regression guard for the live bug: popout table dialogs sit at zIndex.modal (1300), so a +// plain Drawer (1200) opened from inside one is invisible. The sheet claims modal + 1. +export const OverADialog = { + render: () => { + const [dialogOpen, setDialogOpen] = React.useState(true) + return ( + <> + + setDialogOpen(false)} fullWidth> + + + A popout table lives here. Its filter sheet must layer above this dialog. + + + {actionRows} + + + + + ) + }, + play: async ({ step }) => { + const body = within(document.body) + + await step('sheet renders above the dialog', async () => { + await userEvent.click(body.getByRole('button', { name: 'Open filters' })) + const sheetRoot = await waitFor(() => { + const title = body.getByText('Filters') + return title.closest('.MuiDrawer-root') + }) + const dialogRoot = document.querySelector('.MuiDialog-root') + const sheetZ = Number(window.getComputedStyle(sheetRoot).zIndex) + const dialogZ = Number(window.getComputedStyle(dialogRoot).zIndex) + expect(sheetZ).toBeGreaterThan(dialogZ) + }) + }, +} + +// The grab handle used to be decoration — a 36x4 bar that promised a gesture nothing +// implemented. Only a real browser can settle whether the drag works: jsdom has no layout, +// so the paper's height is 0 and the swipe distance the gesture is measured against is +// meaningless there. +export const DragHandleDismisses = { + render: () => ( + + {actionRows} + + ), + play: async ({ canvasElement }) => { + const onAPhone = await shrinkToPhoneViewport() + const canvas = within(canvasElement) + const body = within(document.body) + + await userEvent.click(canvas.getByRole('button', { name: 'Open sheet' })) + await body.findByText('Reset password') + if (!onAPhone) return + + const paper = document.querySelector('.MuiDrawer-paper') + const handle = paper.firstElementChild + const start = handle.getBoundingClientRect() + + // A real touch drag down the screen, starting on the handle. + const at = (clientY) => + new Touch({ + identifier: 1, + target: handle, + clientX: start.x + start.width / 2, + clientY, + }) + // Dispatched ON the handle and left to bubble: MUI reads event.target to decide the + // gesture started inside the paper, so firing at the document would bail immediately. + const fire = (type, clientY) => + handle.dispatchEvent( + new TouchEvent(type, { + bubbles: true, + cancelable: true, + touches: type === 'touchend' ? [] : [at(clientY)], + changedTouches: [at(clientY)], + }) + ) + + // MUI flags "maybe swiping" in React state on touchstart and ignores moves until that + // has been applied, so the gesture has to be spread across ticks like a real one. + const tick = () => new Promise((resolve) => setTimeout(resolve, 30)) + const from = start.y + start.height / 2 + fire('touchstart', from) + await tick() + for (const dy of [20, 60, 120, 200, 260]) { + fire('touchmove', from + dy) + await tick() + } + const draggedTo = new DOMMatrixReadOnly(getComputedStyle(paper).transform).m42 + expect(draggedTo).toBeGreaterThan(100) + fire('touchend', from + 260) + + // The exit has to continue from where the finger let go. Slide probes the paper's + // untranslated position when the exit starts (Slide.js getTranslateValue), and the browser + // takes that probe as the transition's start, which puts the sheet back at full height for + // the length of the close. + const firstExitFrame = await new Promise((resolve) => { + requestAnimationFrame(() => + requestAnimationFrame(() => + resolve(new DOMMatrixReadOnly(getComputedStyle(paper).transform).m42) + ) + ) + }) + expect(firstExitFrame).toBeGreaterThan(draggedTo * 0.6) + + await waitFor(() => expect(body.queryByText('Reset password')).not.toBeInTheDocument()) + }, +} diff --git a/tests/components/CippComponents/CippBreadcrumbNav.test.jsx b/tests/components/CippComponents/CippBreadcrumbNav.test.jsx index 06c181b55374..79794a7045a0 100644 --- a/tests/components/CippComponents/CippBreadcrumbNav.test.jsx +++ b/tests/components/CippComponents/CippBreadcrumbNav.test.jsx @@ -5,10 +5,16 @@ import { CippBreadcrumbNav } from '../../../src/components/CippComponents/CippBr // second require.context consumer, this one globs every pages/**/tabOptions.json. covers the // subdirectory + regex arms of the polyfill that the tutorial glob (flat, no subdirs) doesn't. // 'Groups' only reaches the trail through src/pages/tenant/administration/tenants/tabOptions.json +const routerState = vi.hoisted(() => ({ pathname: '/tenant/administration/tenants/groups' })) +const layoutState = vi.hoisted(() => ({ isMobile: false })) +vi.mock('../../../src/hooks/use-breakpoint', async (importOriginal) => ({ + ...(await importOriginal()), + useIsMobileLayout: () => layoutState.isMobile, +})) vi.mock('next/router', () => ({ useRouter: () => ({ - pathname: '/tenant/administration/tenants/groups', - asPath: '/tenant/administration/tenants/groups', + pathname: routerState.pathname, + asPath: routerState.pathname, query: {}, isReady: true, push: () => Promise.resolve(), @@ -18,6 +24,49 @@ vi.mock('next/router', () => ({ })) describe('CippBreadcrumbNav', () => { + beforeEach(() => { + routerState.pathname = '/tenant/administration/tenants/groups' + layoutState.isMobile = false + }) + + // The dashboard's rail is one crumb saying "Overview" directly above a picker saying + // "Overview" — a single crumb is no hierarchy, so on phones the rail stands down. + it('hides the rail on mobile when there is no hierarchy to show', () => { + routerState.pathname = '/' + layoutState.isMobile = true + renderWithProviders() + + expect(screen.queryByLabelText('page hierarchy')).not.toBeInTheDocument() + }) + + // "Overview > Identity" is the dashboard's own tab set — the exact list the view picker + // beneath it presents, so on phones it says nothing the page doesn't. + it('hides the rail on mobile across all dashboard views, not just the root', () => { + routerState.pathname = '/dashboardv2/identity' + layoutState.isMobile = true + renderWithProviders() + + expect(screen.queryByLabelText('page hierarchy')).not.toBeInTheDocument() + }) + + it('keeps the dashboard rail on desktop', () => { + routerState.pathname = '/dashboardv2/identity' + renderWithProviders() + expect(screen.getByLabelText('page hierarchy')).toBeInTheDocument() + }) + + it('keeps a single-crumb rail on desktop, and deep rails on mobile', () => { + routerState.pathname = '/' + renderWithProviders() + expect(screen.getByLabelText('page hierarchy')).toBeInTheDocument() + }) + + it('keeps a multi-crumb rail on mobile', () => { + layoutState.isMobile = true + renderWithProviders() + expect(screen.getByText('Groups')).toBeInTheDocument() + }) + it('labels the tab crumb from the tabOptions require.context', () => { renderWithProviders() diff --git a/tests/components/CippComponents/CippExpandableAlert.stories.jsx b/tests/components/CippComponents/CippExpandableAlert.stories.jsx new file mode 100644 index 000000000000..19ebf1403927 --- /dev/null +++ b/tests/components/CippComponents/CippExpandableAlert.stories.jsx @@ -0,0 +1,80 @@ +import React from 'react' +import { within, waitFor, expect } from 'storybook/test' +import { userEvent } from 'storybook/test' +import { CippExpandableAlert } from '../../../src/components/CippComponents/CippExpandableAlert' +import { shrinkToPhoneViewport, growToDesktopViewport } from '../../viewport' + +export default { + title: 'Components/CippComponents/CippExpandableAlert', + component: CippExpandableAlert, + tags: ['autodocs'], +} + +const LONG_TEXT = + "Custom roles can be used to restrict permissions for users with the 'editor' or " + + "'readonly' roles in CIPP. They can be limited to a subset of tenants and API permissions. " + + 'Built-in and custom roles can be assigned to Entra security groups for granular access ' + + 'control. This sentence pads the message past any phone clamp so the toggle must appear.' + +const SHORT_TEXT = 'Nothing here needs a second look.' + +// A page-intro alert used to fill most of the first phone screen; the clamp keeps it to a +// few lines and hands the rest to a toggle. +export const ClampsLongMessagesOnAPhone = { + render: () => {LONG_TEXT}, + play: async ({ canvasElement, step }) => { + const onAPhone = await shrinkToPhoneViewport() + if (!onAPhone) return + const canvas = within(canvasElement) + + await step('the message is clipped and offers Show more', async () => { + const toggle = await canvas.findByRole('button', { name: /show more/i }) + const message = canvas.getByText(/Custom roles/, { exact: false }) + expect(message.scrollHeight).toBeGreaterThan(message.clientHeight) + expect(toggle).toBeInTheDocument() + }) + + await step('expanding shows everything and offers Show less', async () => { + await userEvent.click(canvas.getByRole('button', { name: /show more/i })) + const message = canvas.getByText(/Custom roles/, { exact: false }) + await waitFor(() => { + expect(message.scrollHeight).toBeLessThanOrEqual(message.clientHeight + 1) + expect(canvas.getByRole('button', { name: /show less/i })).toBeInTheDocument() + }) + }) + }, +} + +// Measured, not assumed: a message that fits its clamp renders as a plain alert. +export const LeavesShortMessagesAlone = { + render: () => {SHORT_TEXT}, + play: async ({ canvasElement, step }) => { + const onAPhone = await shrinkToPhoneViewport() + if (!onAPhone) return + const canvas = within(canvasElement) + + await step('no toggle for a message that already fits', async () => { + await canvas.findByText(SHORT_TEXT) + await waitFor(() => { + expect(canvas.queryByRole('button', { name: /show more/i })).not.toBeInTheDocument() + }) + }) + }, +} + +export const NeverClampsOnDesktop = { + render: () => {LONG_TEXT}, + play: async ({ canvasElement, step }) => { + const onDesktop = await growToDesktopViewport() + if (!onDesktop) return + const canvas = within(canvasElement) + + await step('full message, no toggle', async () => { + const message = await canvas.findByText(/Custom roles/, { exact: false }) + await waitFor(() => { + expect(message.scrollHeight).toBeLessThanOrEqual(message.clientHeight + 1) + expect(canvas.queryByRole('button', { name: /show more/i })).not.toBeInTheDocument() + }) + }) + }, +} diff --git a/tests/components/CippComponents/CippMobileTenantPicker.stories.jsx b/tests/components/CippComponents/CippMobileTenantPicker.stories.jsx new file mode 100644 index 000000000000..4d68cac88e65 --- /dev/null +++ b/tests/components/CippComponents/CippMobileTenantPicker.stories.jsx @@ -0,0 +1,117 @@ +import React from 'react' +import { http, HttpResponse } from 'msw' +import { within, expect, userEvent, waitFor } from 'storybook/test' +import { Box, Paper, Stack } from '@mui/material' +import { CippMobileTenantPicker } from '../../../src/components/CippComponents/CippMobileTenantPicker' + +const tenants = [ + { customerId: 'all', displayName: 'All Tenants', defaultDomainName: 'AllTenants' }, + { customerId: 't-1', displayName: 'Contoso Ltd', defaultDomainName: 'contoso.com' }, + { customerId: 't-2', displayName: 'Fabrikam Inc', defaultDomainName: 'fabrikam.com' }, + { customerId: 't-3', displayName: 'Northwind Traders', defaultDomainName: 'northwind.com' }, + { customerId: 't-4', displayName: 'Adventure Works', defaultDomainName: 'adventure-works.com' }, +] + +export default { + title: 'Components/CippComponents/CippMobileTenantPicker', + component: CippMobileTenantPicker, + tags: ['autodocs'], + parameters: { + msw: { + handlers: [http.get('*/api/listTenants', () => HttpResponse.json(tenants))], + }, + }, + decorators: [ + (Story) => ( + // Stands in for the mobile top bar, where the chip takes the width a search icon + // used to occupy (universal search moved into the account menu). + + + + + + + + ), + ], +} + +export const Chip = { + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step('chip shows the current tenant name', async () => { + await waitFor(() => expect(canvasElement.textContent).toContain('testdomain.com')) + }) + }, +} + +export const PickerOpen = { + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + const body = within(document.body) + + await step('the chip opens a fullscreen picker listing every tenant', async () => { + await userEvent.click(canvas.getByRole('button')) + await waitFor(() => expect(body.getByText('Contoso Ltd')).toBeInTheDocument()) + expect(body.getByText('Fabrikam Inc')).toBeInTheDocument() + expect(body.getByText('All Tenants')).toBeInTheDocument() + }) + + // Avatar's default colour is background.default, so setting only bgcolor leaves the + // globe a dark grey sitting on the accent. Real browser: read what actually painted. + await step('the All Tenants glyph contrasts with the accent behind it', async () => { + const avatar = body + .getByText('All Tenants') + .closest('[role="button"]') + .querySelector('.MuiAvatar-root') + const style = getComputedStyle(avatar) + expect(style.backgroundColor).not.toBe(style.color) + + const luminance = (rgb) => { + const [r, g, b] = rgb.match(/\d+/g).map(Number) + const channel = (c) => { + const v = c / 255 + return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4 + } + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b) + } + const a = luminance(style.color) + const b = luminance(style.backgroundColor) + const contrast = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) + expect(contrast).toBeGreaterThan(3) + }) + }, +} + +export const SearchFiltersTheList = { + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + const body = within(document.body) + + await userEvent.click(canvas.getByRole('button')) + await waitFor(() => expect(body.getByText('Contoso Ltd')).toBeInTheDocument()) + + await step('search narrows by display name', async () => { + await userEvent.type(body.getByPlaceholderText(/search/i), 'north') + await waitFor(() => expect(body.queryByText('Contoso Ltd')).toBeNull()) + expect(body.getByText('Northwind Traders')).toBeInTheDocument() + }) + }, +} + +export const FavoritingATenant = { + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + const body = within(document.body) + + await userEvent.click(canvas.getByRole('button')) + await waitFor(() => expect(body.getByText('Fabrikam Inc')).toBeInTheDocument()) + + await step('favoriting promotes the tenant into a Favorites section', async () => { + const favoriteButtons = body.getAllByRole('button', { name: /favorite/i }) + await userEvent.click(favoriteButtons[1]) + await waitFor(() => expect(body.getByText('Favorites')).toBeInTheDocument()) + }) + }, +} diff --git a/tests/components/CippComponents/CippOffCanvas.test.jsx b/tests/components/CippComponents/CippOffCanvas.test.jsx index 9bb9a8d39769..dbe2f48737c4 100644 --- a/tests/components/CippComponents/CippOffCanvas.test.jsx +++ b/tests/components/CippComponents/CippOffCanvas.test.jsx @@ -1,9 +1,46 @@ import React, { useState } from 'react' -import { screen, within } from '@testing-library/react' +import { act, cleanup, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { Button } from '@mui/material' import { renderWithTheme } from '../../test-utils' import { CippOffCanvas } from '../../../src/components/CippComponents/CippOffCanvas' +import { resetOverlayHistory } from '../../../src/utils/overlay-history' + +// jsdom has no width-based matchMedia, so the mobile branch has to be stubbed in. Every +// query the drawer asks about below md is a max-width one. +const useMobileViewport = () => { + const cache = new Map() + window.matchMedia = (query) => { + if (!cache.has(query)) { + cache.set(query, { + matches: query.includes('max-width'), + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }) + } + return cache.get(query) + } +} + +const swipeBack = async () => { + await act(async () => { + const settled = new Promise((resolve) => + window.addEventListener('popstate', resolve, { once: true }) + ) + window.history.back() + await settled + }) +} + +afterEach(() => { + resetOverlayHistory() + delete window.matchMedia +}) const mockDeviceData = { displayName: 'DESKTOP-ENTRA-01', @@ -15,7 +52,12 @@ const mockDeviceData = { }, } -const InteractiveWrapper = ({ onClose, onNavigateUp, onNavigateDown, ...props }) => { +const InteractiveWrapper = ({ + onClose, + onNavigateUp, + onNavigateDown, + ...props +}) => { const [open, setOpen] = useState(false) return ( <> @@ -88,6 +130,55 @@ describe('CippOffCanvas', () => { expect(onClose).toHaveBeenCalledTimes(1) }) + it('closes on the phone back gesture instead of navigating the list page away', async () => { + useMobileViewport() + const user = userEvent.setup() + const onClose = vi.fn() + + renderWithTheme( + + ) + + await user.click(screen.getByRole('button', { name: /open offcanvas/i })) + expect(within(document.body).getByText('Device Details')).toBeVisible() + + await swipeBack() + + expect(onClose).toHaveBeenCalledTimes(1) + await waitFor(() => + expect( + within(document.body).queryByText('Device Details') + ).not.toBeInTheDocument() + ) + }) + + it('leaves the back button to the router on desktop', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + + renderWithTheme( + + ) + + await user.click(screen.getByRole('button', { name: /open offcanvas/i })) + // Somewhere to go back to, so the press is a real navigation attempt. + window.history.pushState({}, '') + await swipeBack() + + expect(onClose).not.toHaveBeenCalled() + expect(within(document.body).getByText('Device Details')).toBeVisible() + }) + it('maps extendedInfoFields to values, dotted paths resolve and missing fields fall back to N/A', () => { renderWithTheme( { // field absent from extendedData renders the N/A fallback expect(root.getByText('N/A')).toBeInTheDocument() }) + + it('renders the info card above children by default and below with actionsPosition bottom', () => { + const renderCanvas = (actionsPosition) => { + renderWithTheme( + ( +
    child content
    + )} + /> + ) + } + const childrenBox = () => + within(document.body).getByTestId('custom-children') + const infoValue = () => within(document.body).getByText('DESKTOP-ENTRA-01') + + renderCanvas('top') + expect( + childrenBox().compareDocumentPosition(infoValue()) & + Node.DOCUMENT_POSITION_PRECEDING + ).toBeTruthy() + + cleanup() + renderCanvas('bottom') + expect( + childrenBox().compareDocumentPosition(infoValue()) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy() + }) }) diff --git a/tests/components/CippComponents/CippPageActionsFab.stories.jsx b/tests/components/CippComponents/CippPageActionsFab.stories.jsx new file mode 100644 index 000000000000..5609ef91e917 --- /dev/null +++ b/tests/components/CippComponents/CippPageActionsFab.stories.jsx @@ -0,0 +1,215 @@ +import React from 'react' +import { within, expect, userEvent, waitFor, fn } from 'storybook/test' +import { + Box, + Button, + Divider, + List, + ListItemButton, + ListItemIcon, + ListItemText, + ListSubheader, + MenuItem, + Typography, +} from '@mui/material' +import { Add, Assessment, Public, Summarize } from '@mui/icons-material' +import { CippPageActionsFab } from '../../../src/components/CippComponents/CippPageActionsFab' +import { TabNavigationContext } from '../../../src/layouts/tab-navigation-context' + +const TABS = [ + { label: 'Edit Tenant', path: '/tenant/manage/edit', icon: 'Settings' }, + { label: 'Manage Drift', path: '/tenant/manage/drift', icon: 'Sync' }, + { label: 'Configuration Backup', path: '/tenant/manage/backup', icon: 'Backup' }, +] + +const LAYOUT_ACTIONS = [{ label: 'Reset Password', onClick: () => {} }] + +// Stands in for a headered tabbed layout: below md its header Actions menu is clipped, so +// those actions ride in whichever FAB owns the corner. Its tabs do not — those live in the +// title row (CippTabPicker), which is why this sheet never shows a "Views" section. +const withLayoutActions = (Story) => ( + {}, + actions: LAYOUT_ACTIONS, + claim: () => {}, + release: () => {}, + isActionCornerClaimed: false, + }} + > + + +) + +export default { + title: 'Components/CippComponents/CippPageActionsFab', + component: CippPageActionsFab, + tags: ['autodocs'], + decorators: [ + (Story) => ( + + + Page content. The FAB is fixed to the viewport's bottom-right corner — below md + that corner belongs to page actions (CippSpeedDial hides itself there). + + + + ), + ], +} + +// How table pages use it: cardButton is an arbitrary Box of drawer triggers laid out for a +// desktop CardHeader, restacked vertically by the primitive's descendant CSS. +export const RestackedCardButton = { + render: () => ( + + + + + + + + ), + play: async ({ step }) => { + const body = within(document.body) + + await step('opens the sheet from the FAB', async () => { + await userEvent.click(body.getByRole('button', { name: 'Page actions' })) + await waitFor(() => expect(body.getByText('Actions')).toBeInTheDocument()) + }) + + await step('children are restacked to full width', async () => { + const addButton = body.getByRole('button', { name: 'Add User' }) + expect(window.getComputedStyle(addButton).justifyContent).toBe('flex-start') + }) + + await step('tapping an action closes the sheet', async () => { + await userEvent.click(body.getByRole('button', { name: 'Bulk Add' })) + // keepMounted: a cardButton child owns its own drawer, so the sheet hides rather + // than unmounting — otherwise that drawer would vanish the moment it opened. + await waitFor(() => expect(body.getByText('Actions')).not.toBeVisible()) + }) + }, +} + +// How the dashboard uses it: purpose-built list rows, so restacking is off. +export const DashboardSections = { + render: (args) => ( + + + Portals + + } + > + {['M365', 'Exchange', 'Entra'].map((label) => ( + + + + + + + ))} + + + + Reports + + } + > + {/* ExecutiveReportButton renders exactly this: a MenuItem, not a Button */} + + + + + + + + + + + + + + + ), + args: { + onExecutiveSummary: fn(), + }, + play: async ({ args, step }) => { + const body = within(document.body) + + await step('sections render under their subheaders', async () => { + await userEvent.click(body.getByRole('button', { name: 'Page actions' })) + await waitFor(() => expect(body.getByText('Dashboard actions')).toBeInTheDocument()) + expect(body.getByText('Portals')).toBeInTheDocument() + expect(body.getByText('Reports')).toBeInTheDocument() + }) + + await step('a MenuItem child fires its handler and closes the sheet', async () => { + await userEvent.click(body.getByRole('menuitem', { name: 'Executive Summary' })) + expect(args.onExecutiveSummary).toHaveBeenCalled() + // keepMounted leaves the sheet in the DOM (so ExecutiveReportButton's own preview + // Dialog survives) — closed means hidden here, not unmounted. + await waitFor(() => expect(body.getByText('Dashboard actions')).not.toBeVisible()) + }) + }, +} + +// Under a headered tabbed layout the sheet carries the page's own action and the layout's +// header actions, labelled as two sections. Every page-actions FAB uses the same neutral +// glyph — a "+" only ever told the truth on pages whose sheet creates things. +export const PageAndLayoutActions = { + decorators: [withLayoutActions], + render: () => ( + + + + ), + play: async ({ step }) => { + const body = within(document.body) + + await step('the FAB carries the one shared glyph', async () => { + const fab = body.getByRole('button', { name: 'Page actions' }) + expect(within(fab).queryByTestId('AddIcon')).toBeNull() + expect(within(fab).getByTestId('MoreHorizIcon')).toBeInTheDocument() + }) + + await step('one sheet holds both kinds of action', async () => { + await userEvent.click(body.getByRole('button', { name: 'Page actions' })) + await waitFor(() => expect(body.getByText('Actions')).toBeInTheDocument()) + expect(body.getByRole('button', { name: 'Add Variable' })).toBeInTheDocument() + expect(body.getByText('Reset Password')).toBeInTheDocument() + }) + + // Destinations moved to the title row; a FAB is for a screen's primary action. + await step('and no destinations', async () => { + expect(body.queryByText('Views')).toBeNull() + expect(body.queryByText('Manage Drift')).toBeNull() + expect(body.queryByText('Configuration Backup')).toBeNull() + }) + }, +} diff --git a/tests/components/CippComponents/CippPageActionsFab.test.jsx b/tests/components/CippComponents/CippPageActionsFab.test.jsx new file mode 100644 index 000000000000..9019a21edfbd --- /dev/null +++ b/tests/components/CippComponents/CippPageActionsFab.test.jsx @@ -0,0 +1,197 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Button, Drawer, ListItemButton, MenuItem, Stack, Typography } from "@mui/material"; +import { CippPageActionsFab } from "../../../src/components/CippComponents/CippPageActionsFab"; +import { renderWithProviders } from "../../test-utils"; + +const openSheet = async (user, label = "Page actions") => { + await user.click(screen.getByRole("button", { name: label })); + await screen.findByText("Sheet content"); +}; + +describe("CippPageActionsFab", () => { + it("renders the FAB and opens the sheet with its children", async () => { + const user = userEvent.setup(); + renderWithProviders( + + Sheet content + + + ); + + // keepMounted: the children stay mounted so a child-owned overlay survives the + // sheet closing, so "closed" means hidden rather than absent. + expect(screen.getByText("Sheet content")).not.toBeVisible(); + await openSheet(user); + + expect(screen.getByText("Sheet content")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Do a thing" })).toBeInTheDocument(); + expect(screen.getByText("Actions")).toBeInTheDocument(); + }); + + // A cardButton laid out for a desktop CardHeader is as often a Stack as a Box. Matching + // only Box left the row intact while every button was stretched to 100%, so three import + // buttons ran off the side of the sheet. + it("restacks a row of buttons that arrived as a Stack", async () => { + const user = userEvent.setup(); + renderWithProviders( + + + + + + + + ); + await openSheet(user); + + const row = screen.getByText("Manual Import").closest(".MuiStack-root"); + const styles = window.getComputedStyle(row); + expect(styles.flexDirection).toBe("column"); + // Stack's spacing is a margin-left that would survive the flip and indent each row + const button = screen.getByText("Manual Import").closest("button"); + expect(window.getComputedStyle(button).marginLeft).toBe("0px"); + }); + + // The sheet's paper is grey; a text button's default primary accent reads as + // orange-on-grey and doesn't match the list rows underneath it. + it("neutralises text buttons without flattening the branded ones", async () => { + const user = userEvent.setup(); + renderWithProviders( + <> + + + + + + + + + ); + await openSheet(user); + + // Compared against the same button outside the sheet, so the assertion fails if the + // override goes away rather than merely describing MUI's defaults. + const inSheet = screen.getByText("Sheet content").closest("button"); + const outside = screen.getByText("Untouched").closest("button"); + expect(window.getComputedStyle(inSheet).color).not.toBe( + window.getComputedStyle(outside).color + ); + // a deliberate call to action keeps its branding + expect(screen.getByText("Add User").closest("button").className).toMatch(/containedPrimary/); + }); + + it("uses custom title and aria-label", async () => { + const user = userEvent.setup(); + renderWithProviders( + + Sheet content + + ); + + await openSheet(user, "Dashboard shortcuts"); + expect(screen.getByText("Dashboard actions")).toBeInTheDocument(); + }); + + it("closes the sheet when a child button is tapped", async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + renderWithProviders( + + Sheet content + + + ); + + await openSheet(user); + await user.click(screen.getByRole("button", { name: "Do a thing" })); + + expect(onClick).toHaveBeenCalledTimes(1); + await waitFor(() => expect(screen.getByText("Sheet content")).not.toBeVisible()); + }); + + it("closes the sheet when a child link is tapped", async () => { + const user = userEvent.setup(); + renderWithProviders( + + Sheet content + + External portal + + + ); + + await openSheet(user); + await user.click(screen.getByRole("link", { name: "External portal" })); + + await waitFor(() => expect(screen.getByText("Sheet content")).not.toBeVisible()); + }); + + it("closes the sheet when a MenuItem child is tapped", async () => { + // ExecutiveReportButton renders variant="menuItem" — a
  • , not a button + const user = userEvent.setup(); + const onClick = vi.fn(); + renderWithProviders( + + Sheet content + Executive Summary + + ); + + await openSheet(user); + await user.click(screen.getByRole("menuitem", { name: "Executive Summary" })); + + expect(onClick).toHaveBeenCalledTimes(1); + await waitFor(() => expect(screen.getByText("Sheet content")).not.toBeVisible()); + }); + + it("keeps the sheet open when non-interactive content is tapped", async () => { + const user = userEvent.setup(); + renderWithProviders( + + Sheet content + + ); + + await openSheet(user); + await user.click(screen.getByText("Sheet content")); + + expect(screen.getByText("Sheet content")).toBeInTheDocument(); + }); +}); + +// A cardButton child renders both its trigger and its own overlay (CippAddUserDrawer is a +// button plus a CippOffCanvas). If the sheet unmounts its children on close, that overlay +// disappears the instant it opens. +describe("CippPageActionsFab with a child that owns an overlay", () => { + const DrawerAction = () => { + const [open, setOpen] = React.useState(false); + return ( + <> + + setOpen(false)}> +
    Add user form
    +
    + + ); + }; + + it("keeps the child's overlay open after the sheet closes", async () => { + const user = userEvent.setup(); + renderWithProviders( + + + + ); + + await user.click(screen.getByRole("button", { name: "Page actions" })); + await user.click(await screen.findByRole("button", { name: "Add User" })); + + // the tap closes the sheet and opens the child's drawer — the drawer must survive it + expect(await screen.findByText("Add user form")).toBeInTheDocument(); + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(screen.getByText("Add user form")).toBeInTheDocument(); + }); +}); diff --git a/tests/components/CippComponents/CippQuarantineDetails.test.jsx b/tests/components/CippComponents/CippQuarantineDetails.test.jsx new file mode 100644 index 000000000000..cab1975d1dec --- /dev/null +++ b/tests/components/CippComponents/CippQuarantineDetails.test.jsx @@ -0,0 +1,180 @@ +import React from 'react' +import { screen } from '@testing-library/react' +import { renderWithProviders } from '../../test-utils' +import { api, apiCallMock, getResult } from '../../mocks/api-call' +import { CippQuarantineDetails } from '../../../src/components/CippComponents/CippQuarantineDetails' + +vi.mock('../../../src/api/ApiCall', async () => + (await import('../../mocks/api-call')).apiCallMock() +) + +import TimeAgo from 'javascript-time-ago' +import en from 'javascript-time-ago/locale/en' +try { + TimeAgo.addDefaultLocale(en) +} catch (e) { + /* already added */ +} + +// producer shapes: row is Get-QuarantineMessage output enriched by Add-CIPPQuarantineMessageProperties, +// analyzed is Invoke-ListMailQuarantineMessageDetails Results[0] (analyzedEmails or header fallback) +const quarantineRow = { + Identity: + '5e5e5e5e-1111-2222-3333-444455556666\\c81d4a2e-1111-2222-3333-444455556666', + NetworkMessageId: '5e5e5e5e-1111-2222-3333-444455556666', + Tenant: 'fabrikam.com', + CustomerId: 'customer-1', + Subject: 'Suspicious invoice', + ReceivedTime: '2026-06-01T10:00:00Z', + Expires: '2026-07-01T10:00:00Z', + Type: 'HighConfPhish', + ReleaseStatus: 'NOTRELEASED', + PolicyType: 'AntiPhish', + PolicyName: 'Default AntiPhish', + SenderAddress: 'bad@evil.example', + RecipientAddress: ['user@fabrikam.com'], + Size: 2048, + Direction: 'Inbound', + EntityType: 'Email', + MessageId: '', + QuarantinedUser: 'user@fabrikam.com', + Reported: false, +} + +const analyzed = { + recipientEmailAddress: 'user@fabrikam.com', + internetMessageId: '', + returnPath: 'bounce@evil.example', + directionality: 'Inbound', + language: 'en', + spamConfidenceLevel: -1, + bulkComplaintLevel: 1, + threatTypes: ['Malware'], + detectionMethods: ['File detonation'], + primaryOverrideSource: 'None', + policyAction: 'Quarantine', + senderDetail: { + displayName: 'Evil Sender', + mailFromAddress: 'bad@evil.example', + fromAddress: 'bad@evil.example', + ipv4: '203.0.113.5', + location: 'US', + }, + originalDelivery: { + originalThreats: ['Malware'], + location: 'Quarantine', + action: 'Quarantined', + }, + latestDelivery: { + latestThreats: ['Malware'], + location: 'Quarantine', + action: 'Quarantined', + }, + authenticationDetails: { + dmarc: 'fail', + dkim: 'pass', + senderPolicyFramework: 'softfail', + compositeAuthentication: 'fail', + }, + urls: [ + { + url: 'https://evil.example/pay', + threatType: 'Malware', + detectionMethod: 'Detonated', + }, + ], + attachments: [ + { + fileName: 'invoice.pdf', + contentType: 'application/pdf', + fileSize: 1024, + sha256: + 'aa11bb22cc33dd44ee55ff6677889900aabbccddeeff00112233445566778899', + threatType: 'Malware', + malwareFamily: 'TestFamily', + }, + ], +} + +const detailsResult = (metadata = {}) => + getResult({ data: { Results: [analyzed], Metadata: metadata } }) + +const defaultMetadata = { Available: true, Source: 'Defender' } +const headersResult = detailsResult({ Available: true, Source: 'Headers' }) +const defenderResult = detailsResult(defaultMetadata) + +describe('CippQuarantineDetails', () => { + it('shows the header-parsed fallback notice and targets the row tenant for enrichment', () => { + let detailOpts = null + api.get = (opts) => { + if (opts.url === '/api/ListMailQuarantineMessageDetails') { + detailOpts = opts + return headersResult + } + return getResult() + } + renderWithProviders() + + expect( + screen.getByText(/Showing details parsed from the message headers/) + ).toBeInTheDocument() + expect(detailOpts.data.tenantFilter).toBe('fabrikam.com') + expect(detailOpts.data.Identity).toBe(quarantineRow.Identity) + // fallback fields render from the analyzed-shaped object + expect(screen.getAllByText('Fail').length).toBeGreaterThan(0) + expect(screen.getByText('Softfail')).toBeInTheDocument() + }) + + it('colors phishing and malware reason chips as error', () => { + api.get = () => defenderResult + renderWithProviders() + expect( + screen + .getAllByText('HighConfPhish') + .find((el) => el.closest('[class*="MuiChip-colorError"]')) + ).toBeTruthy() + + api.get = () => defenderResult + renderWithProviders( + + ) + expect( + screen + .getAllByText('Malware') + .find((el) => el.closest('[class*="MuiChip-colorError"]')) + ).toBeTruthy() + }) + + it('colors spam and bulk reason chips as warning', () => { + api.get = () => defenderResult + renderWithProviders( + + ) + expect( + screen + .getAllByText('Spam') + .find((el) => el.closest('[class*="MuiChip-colorWarning"]')) + ).toBeTruthy() + + api.get = () => defenderResult + renderWithProviders( + + ) + expect( + screen + .getAllByText('Bulk') + .find((el) => el.closest('[class*="MuiChip-colorWarning"]')) + ).toBeTruthy() + }) + + it('renders URL and attachment verdict tables from the analyzed enrichment', () => { + api.get = () => defenderResult + renderWithProviders() + + expect(screen.getByText('https://evil.example/pay')).toBeInTheDocument() + expect(screen.getByText('Detonated')).toBeInTheDocument() + expect(screen.getByText('invoice.pdf')).toBeInTheDocument() + expect(screen.getByText('TestFamily')).toBeInTheDocument() + expect(screen.getByText('1.0 KB')).toBeInTheDocument() + }) +}) diff --git a/tests/components/CippComponents/CippQuarantineTable.test.jsx b/tests/components/CippComponents/CippQuarantineTable.test.jsx new file mode 100644 index 000000000000..84bd7efb7c20 --- /dev/null +++ b/tests/components/CippComponents/CippQuarantineTable.test.jsx @@ -0,0 +1,98 @@ +import React from 'react' +import { act, screen } from '@testing-library/react' +import { renderWithProviders, settingsWith } from '../../test-utils' +import { api, apiCallMock, getResult } from '../../mocks/api-call' +import { CippQuarantineTable } from '../../../src/components/CippComponents/CippQuarantineTable' + +const tableProps = vi.hoisted(() => ({ current: null })) +vi.mock('../../../src/api/ApiCall', async () => + (await import('../../mocks/api-call')).apiCallMock() +) +vi.mock('../../../src/components/CippComponents/CippTablePage.jsx', () => ({ + CippTablePage: (props) => { + tableProps.current = props + return
    + }, +})) + +const quarantineRow = { + Identity: + '5e5e5e5e-1111-2222-3333-444455556666\\c81d4a2e-1111-2222-3333-444455556666', + NetworkMessageId: '5e5e5e5e-1111-2222-3333-444455556666', + Tenant: 'fabrikam.com', + Subject: 'Suspicious invoice', + MessageId: '', + ReceivedTime: '2026-06-01T10:00:00Z', + RecipientAddress: ['user@fabrikam.com'], + ReleaseStatus: 'NOTRELEASED', +} + +describe('CippQuarantineTable', () => { + it('gates email-only actions to the Email tab and passes the entity type to the API', () => { + api.get = () => getResult() + const { unmount } = renderWithProviders( + + ) + const { actions, apiData } = tableProps.current + const labels = actions.map((action) => action.label) + + expect(labels).toContain('Release') + expect(labels).toContain('Delete from Quarantine') + expect(labels).not.toContain('Preview Message') + expect(labels).not.toContain('Deny') + expect(labels).not.toContain('Block Sender') + expect(labels).not.toContain('Submit to Microsoft for Review') + expect(labels).not.toContain('Open Email Entity in Defender') + expect(apiData.EntityType).toBe('Teams') + unmount() + + renderWithProviders() + const emailLabels = tableProps.current.actions.map((action) => action.label) + expect(emailLabels).toContain('Preview Message') + expect(emailLabels).toContain('Deny') + expect(emailLabels).toContain('Submit to Microsoft for Review') + expect(emailLabels).toContain('Block Sender') + expect(emailLabels).toContain('Open Email Entity in Defender') + }) + + it('targets the row tenant for per-message calls in the AllTenants view', async () => { + const callOpts = [] + api.get = (opts) => { + callOpts.push(opts) + return getResult() + } + renderWithProviders(, { + settings: settingsWith({ currentTenant: 'AllTenants' }), + }) + + const preview = tableProps.current.actions.find( + (action) => action.label === 'Preview Message' + ) + await act(async () => preview.customFunction(quarantineRow)) + + const contentsCall = callOpts.find( + (opts) => + opts.url === '/api/ListMailQuarantineMessage' && + opts.data?.Identity === quarantineRow.Identity + ) + expect(contentsCall).toBeTruthy() + expect(contentsCall.data.tenantFilter).toBe('fabrikam.com') + }) + + it('renders the raw message headers in the headers dialog', async () => { + const headerText = + 'Received: from mail.evil.example\r\nX-CIPP-Test: present' + api.get = (opts) => + opts.url === '/api/ListMailQuarantineMessageHeader' + ? getResult({ data: { Header: headerText } }) + : getResult() + renderWithProviders() + + const viewHeaders = tableProps.current.actions.find( + (action) => action.label === 'View Message Headers' + ) + await act(async () => viewHeaders.customFunction(quarantineRow)) + + expect(screen.getByText(/X-CIPP-Test: present/)).toBeInTheDocument() + }) +}) diff --git a/tests/components/CippComponents/CippReportToolbar.stories.jsx b/tests/components/CippComponents/CippReportToolbar.stories.jsx new file mode 100644 index 000000000000..20f37116f888 --- /dev/null +++ b/tests/components/CippComponents/CippReportToolbar.stories.jsx @@ -0,0 +1,99 @@ +import React from 'react' +import { http, HttpResponse } from 'msw' +import { within, expect, userEvent, waitFor } from 'storybook/test' +import { Box } from '@mui/material' +import { CippReportToolbar } from '../../../src/components/CippComponents/CippReportToolbar' + +const testSuites = [ + { + id: 'ztna', + name: 'Zero Trust Network Access Tests', + description: "Microsoft's comprehensive security assessment", + type: 'builtin', + source: 'file', + }, + { + id: 'custom-1', + name: 'My Custom Suite', + description: 'A tenant-specific suite', + type: 'custom', + source: 'table', + }, +] + +const handlers = [ + http.get('*/api/ListTestReports', () => HttpResponse.json(testSuites)), + http.get('*/api/ListAvailableTests', () => + HttpResponse.json({ IdentityTests: [], DevicesTests: [], CustomTests: [] }) + ), +] + +export default { + title: 'Components/CippComponents/CippReportToolbar', + component: CippReportToolbar, + tags: ['autodocs'], + parameters: { msw: { handlers } }, + decorators: [ + (Story) => ( + + + + ), + ], +} + +// The toolbar picks its layout from useIsMobileLayout (a media query), and no story in this +// repo sets a viewport — so the mobile variant is shown by constraining the container and +// documenting the difference rather than by faking the breakpoint. +export const Desktop = { + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step('every suite action is an inline button', async () => { + await waitFor(() => + expect(canvas.getByRole('button', { name: 'Refresh' })).toBeInTheDocument() + ) + expect(canvas.getByRole('button', { name: 'Delete' })).toBeInTheDocument() + expect(canvas.getByRole('button', { name: 'Create Suite' })).toBeInTheDocument() + expect(canvas.getByRole('button', { name: 'Refresh test suites' })).toBeInTheDocument() + expect(canvas.queryByRole('button', { name: 'Test suite actions' })).toBeNull() + }) + }, +} + +// Regression guard for the overflow this refactor fixed: the selector must be allowed to +// shrink (minWidth: 0) so the trailing Delete button stays inside the row. +export const NarrowDesktopKeepsButtonsInView = { + decorators: [ + (Story) => ( + + + + ), + ], + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step('the last button is not pushed past the container edge', async () => { + const deleteButton = await waitFor(() => canvas.getByRole('button', { name: 'Delete' })) + const row = deleteButton.closest('div[class*="MuiBox"]').parentElement + expect(deleteButton.getBoundingClientRect().right).toBeLessThanOrEqual( + Math.ceil(row.getBoundingClientRect().right) + 1 + ) + }) + }, +} + +export const SuiteSelection = { + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step('the default suite is selected once the list loads', async () => { + // Opening the popper is left to CippAutocomplete's own stories — driving it from here + // crashes the browser tab in this harness. + await waitFor(() => + expect(canvas.getByRole('combobox')).toHaveValue('Zero Trust Network Access Tests') + ) + }) + }, +} diff --git a/tests/components/CippComponents/CippReportToolbar.test.jsx b/tests/components/CippComponents/CippReportToolbar.test.jsx new file mode 100644 index 000000000000..3d94facfa05d --- /dev/null +++ b/tests/components/CippComponents/CippReportToolbar.test.jsx @@ -0,0 +1,237 @@ +import React from "react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../test-utils"; + +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ isMobile: false })); +vi.mock("../../../src/hooks/use-breakpoint", () => ({ + useIsMobileLayout: () => layoutState.isMobile, + useIsTabletLayout: () => false, + useTableViewMode: () => "table", +})); + +// One registration only — ApiCall and ApiCall.jsx resolve to the same module, so a second +// vi.mock for the extensioned path would silently replace this one. +const apiState = vi.hoisted(() => ({ reports: [], refetch: () => {}, reportsResult: null })); +const idlePaginated = vi.hoisted(() => ({ + isSuccess: false, + isFetching: false, + isLoading: false, + isError: false, + data: undefined, + fetchNextPage: () => {}, + refetch: () => {}, +})); +const idlePost = vi.hoisted(() => ({ + mutate: () => {}, + isPending: false, + isSuccess: false, + isError: false, + reset: () => {}, +})); +const idleGet = vi.hoisted(() => ({ + isSuccess: false, + isFetching: false, + isLoading: false, + isError: false, + data: undefined, + refetch: () => {}, +})); +vi.mock("../../../src/api/ApiCall", () => ({ + // Stable result identity per test: a fresh literal each call loops the autocomplete's + // option-mapping effect (see tests/mocks/api-call.js). + ApiGetCall: ({ url }) => + url === "/api/ListTestReports" ? apiState.reportsResult : idleGet, + ApiGetCallWithPagination: () => idlePaginated, + ApiPostCall: () => idlePost, +})); + +const routerState = vi.hoisted(() => ({ push: vi.fn(), query: {} })); +vi.mock("next/router", () => ({ + useRouter: () => ({ + isReady: true, + pathname: "/dashboardv2", + query: routerState.query, + push: routerState.push, + }), +})); + +// The drawer pulls in the whole test-picker form; the toolbar contract under test is only +// "is it open, and with which suite" — so it's stubbed down to those observable facts. +const drawerRenders = vi.hoisted(() => ({ calls: [] })); +vi.mock("../../../src/components/CippComponents/CippAddTestReportDrawer", () => ({ + CippAddTestReportDrawer: (props) => { + drawerRenders.calls.push(props); + if (props.hideTrigger) { + return props.open ? ( +
    + {props.reportToEdit?.name ?? "no-report"} +
    + ) : null; + } + return ; + }, +})); + +vi.mock("../../../src/components/CippComponents/CippApiDialog", () => ({ + CippApiDialog: ({ createDialog, title }) => + createDialog?.open ?
    {title}
    : null, +})); + +import { CippReportToolbar } from "../../../src/components/CippComponents/CippReportToolbar"; + +const CUSTOM_SUITE = { + id: "custom-1", + name: "My Custom Suite", + description: "custom", + type: "custom", + source: "table", +}; +const BUILT_IN_SUITE = { + id: "ztna", + name: "Zero Trust Network Access Tests", + description: "built in", + type: "builtin", + source: "file", +}; + +const openActionSheet = async (user) => { + await user.click(screen.getByRole("button", { name: "Test suite actions" })); + const heading = await screen.findByText("Test suite actions"); + return within(heading.closest(".MuiDrawer-paper")); +}; + +describe("CippReportToolbar", () => { + beforeEach(() => { + layoutState.isMobile = false; + apiState.reports = [BUILT_IN_SUITE, CUSTOM_SUITE]; + apiState.refetch = vi.fn(); + apiState.reportsResult = { + isSuccess: true, + isFetching: false, + isLoading: false, + isError: false, + data: apiState.reports, + refetch: apiState.refetch, + }; + routerState.query = {}; + routerState.push = vi.fn(); + drawerRenders.calls = []; + }); + + it("renders the inline desktop action buttons", () => { + renderWithProviders(); + + expect(screen.getByRole("button", { name: "Refresh" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Create Suite" })).toBeInTheDocument(); + // The selector's inline "Refresh test suites" icon button is desktop-only too + expect(screen.getByRole("button", { name: "Refresh test suites" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Test suite actions" })).not.toBeInTheDocument(); + }); + + it("collapses to a sheet trigger + kebab on mobile — no text input, no keyboard", () => { + layoutState.isMobile = true; + routerState.query = { reportId: "ztna" }; + renderWithProviders(); + + expect(screen.getByRole("button", { name: "Test suite actions" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Delete" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Refresh" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Refresh test suites" })).not.toBeInTheDocument(); + // the house pick-one pattern: a trigger, not an autocomplete + expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /switch test suite/i })).toHaveTextContent( + "Zero Trust Network Access Tests" + ); + }); + + it("switches suite from the bottom sheet, routing shallowly", async () => { + layoutState.isMobile = true; + routerState.query = { reportId: "ztna" }; + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /switch test suite/i })); + const sheet = within((await screen.findByText("Test suite")).closest(".MuiDrawer-paper")); + // descriptions ride as secondary text, the current suite is checked + expect(sheet.getByText("custom")).toBeInTheDocument(); + expect(sheet.getByText("Zero Trust Network Access Tests").closest('[role="button"]')).toHaveClass( + "Mui-selected" + ); + + await user.click(sheet.getByText("My Custom Suite")); + await waitFor(() => + expect(routerState.push).toHaveBeenCalledWith( + expect.objectContaining({ query: expect.objectContaining({ reportId: "custom-1" }) }), + undefined, + { shallow: true } + ) + ); + }); + + it("offers all five suite actions in the sheet", async () => { + layoutState.isMobile = true; + const user = userEvent.setup(); + renderWithProviders(); + + const sheet = await openActionSheet(user); + ["Create Suite", "Run Tests", "Edit Suite", "Delete Suite", "Reload suite list"].forEach( + (label) => expect(sheet.getByText(label)).toBeInTheDocument() + ); + }); + + it("disables Edit and Delete with a visible reason for a built-in suite", async () => { + layoutState.isMobile = true; + routerState.query = { reportId: "ztna" }; + const user = userEvent.setup(); + renderWithProviders(); + + const sheet = await openActionSheet(user); + expect(sheet.getByText("Built-in test suites cannot be edited")).toBeInTheDocument(); + expect(sheet.getByText("Built-in test suites cannot be deleted")).toBeInTheDocument(); + expect(sheet.getByText("Edit Suite").closest("[role='button']")).toHaveClass("Mui-disabled"); + }); + + it("opens the run-tests dialog and keeps it mounted after the sheet closes", async () => { + layoutState.isMobile = true; + const user = userEvent.setup(); + renderWithProviders(); + + const sheet = await openActionSheet(user); + await user.click(sheet.getByText("Run Tests")); + + expect(await screen.findByTestId("api-dialog")).toHaveTextContent("Refresh Test Data"); + await waitFor(() => + expect(screen.queryByText("Test suite actions")).not.toBeInTheDocument() + ); + expect(screen.getByTestId("api-dialog")).toBeInTheDocument(); + }); + + it("opens the edit drawer pre-filled with the selected custom suite", async () => { + layoutState.isMobile = true; + routerState.query = { reportId: "custom-1" }; + const user = userEvent.setup(); + renderWithProviders(); + + const sheet = await openActionSheet(user); + await user.click(sheet.getByText("Edit Suite")); + + const drawer = await screen.findByTestId("drawer-edit"); + expect(drawer).toHaveTextContent("My Custom Suite"); + }); + + it("reloads the suite list from the sheet", async () => { + layoutState.isMobile = true; + const user = userEvent.setup(); + renderWithProviders(); + + const sheet = await openActionSheet(user); + await user.click(sheet.getByText("Reload suite list")); + + // the sheet hands off on its exit transition, so the call lands a beat later + await waitFor(() => expect(apiState.refetch).toHaveBeenCalled()); + }); +}); diff --git a/tests/components/CippComponents/CippSankey.test.jsx b/tests/components/CippComponents/CippSankey.test.jsx new file mode 100644 index 000000000000..85735229d851 --- /dev/null +++ b/tests/components/CippComponents/CippSankey.test.jsx @@ -0,0 +1,61 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { renderWithProviders, settingsWith } from "../../test-utils"; +import { createTheme } from "../../../src/theme"; + +// jsdom gives nivo's responsive wrapper a 0×0 parent, so nothing paints — capture the +// props instead and assert on the dark/light decisions they encode. +const captured = vi.hoisted(() => ({ props: null })); +vi.mock("@nivo/sankey", () => ({ + ResponsiveSankey: (props) => { + captured.props = props; + return null; + }, +})); + +vi.mock("../../../src/hooks/use-breakpoint", async (importOriginal) => ({ + ...(await importOriginal()), + useIsMobileLayout: () => false, +})); + +import { CippSankey } from "../../../src/components/CippComponents/CippSankey"; + +const data = { + nodes: [ + { id: "Users", nodeColor: "#f97316" }, + { id: "MFA", nodeColor: "#22c55e" }, + ], + links: [{ source: "Users", target: "MFA", value: 5 }], +}; + +const darkTheme = createTheme({ + colorPreset: "orange", + direction: "ltr", + paletteMode: "dark", + contrast: "high", +}); + +describe("CippSankey theming", () => { + // The app resolves currentTheme "browser" to the OS preference when building the MUI + // theme, so the *setting* can say "browser" while the page paints dark. Deciding + // darkness from the setting made the chart multiply its ribbons over a dark card — + // composited to black, i.e. an invisible chart until the user toggled the theme. + it("follows the painted palette, not the theme setting", () => { + renderWithProviders(, { + theme: darkTheme, + settings: settingsWith({ currentTheme: { value: "browser", label: "Browser default" } }), + }); + + expect(captured.props.linkBlendMode).toBe("lighten"); + expect(captured.props.labelTextColor).toBe("#ffffff"); + }); + + it("keeps multiply-over-white on an actually light page", () => { + renderWithProviders(, { + settings: settingsWith({ currentTheme: { value: "browser", label: "Browser default" } }), + }); + + expect(captured.props.linkBlendMode).toBe("multiply"); + expect(captured.props.labelTextColor).toBe("#000000"); + }); +}); diff --git a/tests/components/CippComponents/CippSettingsSideBar.test.jsx b/tests/components/CippComponents/CippSettingsSideBar.test.jsx new file mode 100644 index 000000000000..2362102662a9 --- /dev/null +++ b/tests/components/CippComponents/CippSettingsSideBar.test.jsx @@ -0,0 +1,46 @@ +import React from 'react' +import { describe, it, expect, vi } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useForm } from 'react-hook-form' +import { renderWithProviders } from '../../test-utils' + +vi.mock('../../../src/api/ApiCall', async () => (await import('../../mocks/api-call')).apiCallMock()) +import { api, getResult, postResult } from '../../mocks/api-call' + +import { CippSettingsSideBar } from '../../../src/components/CippComponents/CippSettingsSideBar' + +const meResult = getResult({ data: { clientPrincipal: { userDetails: 'admin@contoso.com' } } }) +api.get = meResult + +// handleSaveChanges posts an explicit field allowlist, a preference missing from it saves +// as a silent no-op ("Settings saved successfully" toast, nothing stored) +const Harness = () => { + const formcontrol = useForm({ + defaultValues: { + user: { label: 'Current User', value: 'admin@contoso.com' }, + tableViewMode: { value: 'table', label: 'Always classic table' }, + tablePageSize: { value: '50', label: '50' }, + }, + }) + return +} + +describe('CippSettingsSideBar save allowlist', () => { + it('Save Changes posts tableViewMode with the settings blob', async () => { + const user = userEvent.setup() + api.post = postResult() + renderWithProviders() + + await user.click(await screen.findByRole('button', { name: /save changes/i })) + + await waitFor(() => expect(api.post.mutate).toHaveBeenCalled()) + const payload = api.post.mutate.mock.calls[0][0] + expect(payload.data.user).toBe('admin@contoso.com') + expect(payload.data.currentSettings.tableViewMode).toEqual({ + value: 'table', + label: 'Always classic table', + }) + expect(payload.data.currentSettings.tablePageSize).toEqual({ value: '50', label: '50' }) + }) +}) diff --git a/tests/components/CippComponents/CippTabPicker.stories.jsx b/tests/components/CippComponents/CippTabPicker.stories.jsx new file mode 100644 index 000000000000..4303a3b6cfab --- /dev/null +++ b/tests/components/CippComponents/CippTabPicker.stories.jsx @@ -0,0 +1,162 @@ +import React from 'react' +import { within, userEvent, waitFor, expect } from 'storybook/test' +import { Box, Stack, Typography } from '@mui/material' +import { CippTabPicker } from '../../../src/components/CippComponents/CippTabPicker' +import { TabNavigationContext } from '../../../src/layouts/tab-navigation-context' +import { shrinkToPhoneViewport } from '../../viewport' + +// tenant/manage — the worst group in the app for label length. 30 characters on the longest. +const TABS = [ + { label: 'Edit Tenant', path: '/tenant/manage/edit', icon: 'Settings' }, + { label: 'Manage Drift', path: '/tenant/manage/drift', icon: 'Sync' }, + { label: 'Configuration Backup', path: '/tenant/manage/backup', icon: 'Backup' }, + { label: 'Applied Standards Report', path: '/tenant/manage/standards', icon: 'Assessment' }, + { + label: 'Policies and Settings Deployed', + path: '/tenant/manage/policies', + icon: 'Assessment', + }, +] + +const withTabs = + (currentPath = '/tenant/manage/policies', tabs = TABS) => + (Story) => ( + {}, + actions: [], + claim: () => {}, + release: () => {}, + isActionCornerClaimed: false, + }} + > + + + ) + +export default { + title: 'Components/CippComponents/CippTabPicker', + component: CippTabPicker, + tags: ['autodocs'], +} + +// The default, and what every tabbed page gets: one full-width control in the slot the +// desktop tab bar occupied. Same control, same place, every page. +export const BlockAtPhoneWidth = { + decorators: [withTabs()], + render: () => ( + + + + ), + play: async ({ canvasElement, step }) => { + const onAPhone = await shrinkToPhoneViewport() + const canvas = within(canvasElement) + const picker = canvas.getByRole('button', { name: /switch view/i }) + + await step('the trigger names the current view', async () => { + await expect(picker).toHaveAccessibleName('Policies and Settings Deployed switch view') + }) + + if (!onAPhone) return + + await step('the longest label in the app fits without widening the page', async () => { + const host = canvasElement.querySelector('[data-testid="block-host"]') + await waitFor(() => expect(host.scrollWidth).toBeLessThanOrEqual(host.clientWidth)) + // full width of the gutter box, so the control is unmistakably a control + const style = getComputedStyle(host) + const content = + host.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight) + await expect(picker.getBoundingClientRect().width).toBeGreaterThan(content - 1) + }) + + // Heading clothes: the chevron rides beside the text like a title's disclosure + // affordance, not pinned to the far edge like a form field's. + await step('the chevron sits beside the label, not at the far edge', async () => { + const chevron = picker.querySelector('svg:last-of-type') + const labelEl = within(picker).getByText('Policies and Settings Deployed') + const gapToLabel = chevron.getBoundingClientRect().left - labelEl.getBoundingClientRect().right + await expect(gapToLabel).toBeLessThan(24) + }) + }, +} + +// The one exception: HeaderedTabbedLayout's title row is empty on its right half below md, +// so the picker rides there and navigation costs no vertical space at all. +export const CompactInTitleRow = { + decorators: [withTabs()], + render: () => ( + + + + + Contoso Manufacturing Holdings GmbH + + + + + + ), + play: async ({ canvasElement, step }) => { + const onAPhone = await shrinkToPhoneViewport() + if (!onAPhone) return + const canvas = within(canvasElement) + const picker = canvas.getByRole('button', { name: /switch view/i }) + + await step('a 30-char label beside a long title does not widen the row', async () => { + const host = canvasElement.querySelector('[data-testid="title-row-host"]') + await waitFor(() => expect(host.scrollWidth).toBeLessThanOrEqual(host.clientWidth)) + // and it stays a control rather than eating the heading's half of the row + await expect(picker.getBoundingClientRect().width).toBeLessThanOrEqual( + host.clientWidth / 2 + 1 + ) + }) + }, +} + +// A single destination is not navigation — View Group and View Device have one tab each. +export const SingleTabRendersNothing = { + decorators: [withTabs('/identity/groups/group', [TABS[0]])], + render: () => ( + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await expect(canvas.queryByRole('button', { name: /switch view/i })).toBeNull() + }, +} + +export const OpensTheSheet = { + decorators: [withTabs('/tenant/manage/edit')], + render: () => ( + + + + ), + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + // The trigger names the current view and so does its row in the sheet — scope to the + // sheet, or every current-tab query matches twice. + let sheet + + await step('every destination is a full-width row, none scrolled off an edge', async () => { + await userEvent.click(canvas.getByRole('button', { name: /switch view/i })) + const body = within(document.body) + await waitFor(() => expect(body.getByText('Views')).toBeInTheDocument()) + sheet = within(body.getByText('Views').closest('.MuiDrawer-paper')) + await expect(sheet.getByText('Configuration Backup')).toBeInTheDocument() + await expect(sheet.getByText('Policies and Settings Deployed')).toBeInTheDocument() + }) + + await step('the current view is checked', async () => { + const current = sheet.getByText('Edit Tenant').closest('[role="button"]') + await expect(current).toHaveClass('Mui-selected') + }) + }, +} diff --git a/tests/components/CippComponents/CippUserSwitcher.test.jsx b/tests/components/CippComponents/CippUserSwitcher.test.jsx new file mode 100644 index 000000000000..a26931fe95f4 --- /dev/null +++ b/tests/components/CippComponents/CippUserSwitcher.test.jsx @@ -0,0 +1,104 @@ +import React from "react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../test-utils"; + +const layoutState = vi.hoisted(() => ({ isMobile: false })); +vi.mock("../../../src/hooks/use-breakpoint", async (importOriginal) => ({ + ...(await importOriginal()), + useIsMobileLayout: () => layoutState.isMobile, +})); + +// Stable identities (tests/mocks/api-call.js): fresh objects per call re-render forever +const routerState = vi.hoisted(() => { + const router = { + push: () => {}, + pathname: "/identity/administration/users/user", + query: { userId: "user-1", tenantFilter: "contoso.com" }, + }; + return { router }; +}); +vi.mock("next/router", () => ({ useRouter: () => routerState.router })); + +const apiState = vi.hoisted(() => ({ + result: { isFetching: false, isSuccess: true, data: { Results: [] } }, +})); +vi.mock("../../../src/api/ApiCall", () => ({ + ApiGetCall: () => apiState.result, +})); + +import { CippUserSwitcher } from "../../../src/components/CippComponents/CippUserSwitcher"; + +const users = [ + { id: "user-1", displayName: "Ada Lovelace", userPrincipalName: "ada@contoso.com" }, + { id: "user-2", displayName: "Grace Hopper", userPrincipalName: "grace@contoso.com" }, + { id: "user-3", displayName: "Alan Turing", userPrincipalName: "alan@contoso.com" }, +]; + +const renderSwitcher = () => + renderWithProviders( + + ); + +describe("CippUserSwitcher", () => { + beforeEach(() => { + layoutState.isMobile = false; + routerState.router.push = vi.fn(); + apiState.result = { isFetching: false, isSuccess: true, data: { Results: users } }; + }); + + it("keeps the visible name in the accessible name", () => { + renderSwitcher(); + expect( + screen.getByRole("button", { name: /Ada Lovelace switch user/i }) + ).toBeInTheDocument(); + }); + + it("switches only the userId, keeping route and tenant", async () => { + const user = userEvent.setup(); + renderSwitcher(); + + await user.click(screen.getByRole("button", { name: /switch user/i })); + await user.click(await screen.findByText("Grace Hopper")); + + expect(routerState.router.push).toHaveBeenCalledWith({ + pathname: "/identity/administration/users/user", + query: { userId: "user-2", tenantFilter: "contoso.com" }, + }); + }); + + it("treats picking the current user as a no-op", async () => { + const user = userEvent.setup(); + renderSwitcher(); + + await user.click(screen.getByRole("button", { name: /switch user/i })); + // the popover lists the current user too — pick the row, not the trigger's own text + const rows = await screen.findAllByText("Ada Lovelace"); + await user.click(rows[rows.length - 1]); + + expect(routerState.router.push).not.toHaveBeenCalled(); + }); + + it("filters by name or UPN", async () => { + const user = userEvent.setup(); + renderSwitcher(); + + await user.click(screen.getByRole("button", { name: /switch user/i })); + await user.type(await screen.findByPlaceholderText(/search users/i), "alan@"); + + const list = screen.getByRole("list"); + expect(within(list).getByText("Alan Turing")).toBeInTheDocument(); + expect(within(list).queryByText("Grace Hopper")).not.toBeInTheDocument(); + }); + + it("uses the bottom sheet on mobile", async () => { + layoutState.isMobile = true; + const user = userEvent.setup(); + renderSwitcher(); + + await user.click(screen.getByRole("button", { name: /switch user/i })); + const sheet = (await screen.findByText("Grace Hopper")).closest(".MuiDrawer-paper"); + expect(sheet).not.toBeNull(); + }); +}); diff --git a/tests/components/CippComponents/SecureScoreCard.test.jsx b/tests/components/CippComponents/SecureScoreCard.test.jsx index bd47877ebda4..9184157d519e 100644 --- a/tests/components/CippComponents/SecureScoreCard.test.jsx +++ b/tests/components/CippComponents/SecureScoreCard.test.jsx @@ -1,7 +1,19 @@ import React from 'react' import { screen } from '@testing-library/react' import { renderWithTheme } from '../../test-utils' -import { SecureScoreCard } from '../../../src/components/CippComponents/SecureScoreCard' + +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ isMobile: false })) +vi.mock('../../../src/hooks/use-breakpoint', () => ({ + useIsMobileLayout: () => layoutState.isMobile, + useIsTabletLayout: () => false, + useTableViewMode: () => 'table', +})) + +import { + SecureScoreCard, + secureScoreAxisProps, +} from '../../../src/components/CippComponents/SecureScoreCard' const scoreData = [ { createdDateTime: '2026-07-01T00:00:00Z', currentScore: 40, maxScore: 100 }, @@ -10,6 +22,36 @@ const scoreData = [ ] describe('SecureScoreCard', () => { + beforeEach(() => { + layoutState.isMobile = false + }) + + // recharts reads its axis children's props without mounting them, so there is no element to + // assert against — the config is exported and tested directly. + const ticks = ['Jul 1', 'Jul 15', 'Jul 29'] + + // interval 0 draws a label for every point. Thirteen dates fit across a desktop card and + // overlap into one smear at 390px, which is what "Jul 27Jul 28Jul 29" looks like. + it('labels every point on desktop', () => { + const axis = secureScoreAxisProps({ isMobile: false, ticks }) + + expect(axis.x.interval).toBe(0) + expect(axis.x.ticks).toBe(ticks) + expect(axis.x.tick.fontSize).toBe(12) + expect(axis.y.width).toBeUndefined() + }) + + it('hands x-axis spacing back to recharts on a narrow chart', () => { + const axis = secureScoreAxisProps({ isMobile: true, ticks }) + + expect(axis.x.interval).toBe('preserveStartEnd') + expect(axis.x.ticks).toBeUndefined() + expect(axis.x.minTickGap).toBeGreaterThan(5) + expect(axis.x.tick.fontSize).toBeLessThan(12) + // and the y-axis gutter narrows so the plot keeps the width it has + expect(axis.y.width).toBeLessThan(40) + }) + it('does not trigger the recharts zero-size warning on first render', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) diff --git a/tests/components/CippFormPages/CippFormPage.test.jsx b/tests/components/CippFormPages/CippFormPage.test.jsx index 62e38136817a..be8fbbe205a9 100644 --- a/tests/components/CippFormPages/CippFormPage.test.jsx +++ b/tests/components/CippFormPages/CippFormPage.test.jsx @@ -1,90 +1,109 @@ -import React from 'react' -import { screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { useForm } from 'react-hook-form' -import { renderWithProviders } from '../../test-utils' -import CippFormPage from '../../../src/components/CippFormPages/CippFormPage' -import CippFormComponent from '../../../src/components/CippComponents/CippFormComponent' +import React from "react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import { useForm } from "react-hook-form"; +import { renderWithProviders } from "../../test-utils"; -// capture the submit payload, network layer is not under test here -const apiState = vi.hoisted(() => ({ mutate: null })) +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ isMobile: false })); +vi.mock("../../../src/hooks/use-breakpoint", async (importOriginal) => ({ + ...(await importOriginal()), + useIsMobileLayout: () => layoutState.isMobile, + useIsTabletLayout: () => false, +})); -vi.mock('../../../src/api/ApiCall', () => ({ - ApiPostCall: () => ({ - mutate: apiState.mutate, - isPending: false, - isSuccess: false, - isIdle: true, - isError: false, - isFetching: false, - data: undefined, - reset: () => {}, - }), - // CippApiResults polls job status through ApiGetCall, keep it inert - ApiGetCall: () => ({ - isSuccess: false, - isPending: true, - isFetching: false, - isError: false, - data: undefined, - }), -})) +// Stable identities: CippFormPage has a useEffect keyed on the router object itself that +// resets the form — a fresh object per call re-renders forever (tests/mocks/api-call.js) +const routerState = vi.hoisted(() => { + const router = { push: () => {}, back: () => {}, query: {} }; + return { push: router.push, pathname: "/cipp/sam-roles", router }; +}); +vi.mock("next/navigation", () => ({ + useRouter: () => routerState.router, + usePathname: () => routerState.pathname, + useSearchParams: () => new URLSearchParams(""), +})); +vi.mock("next/router", () => ({ + useRouter: () => routerState.router, +})); -const Harness = ({ defaultValues = { displayName: '', notes: '' }, ...pageProps }) => { - const formControl = useForm({ mode: 'onChange', defaultValues }) +// Stable identities: a fresh object per call re-renders forever (tests/mocks/api-call.js) +const idle = vi.hoisted(() => ({ + isSuccess: false, + isFetching: false, + isPending: false, + isError: false, + isIdle: true, + data: undefined, + mutate: () => {}, + reset: () => {}, + refetch: () => {}, +})); +vi.mock("../../../src/api/ApiCall", () => ({ + ApiGetCall: () => idle, + ApiPostCall: () => idle, + ApiGetCallWithPagination: () => ({ ...idle, fetchNextPage: () => {} }), +})); + +import { TabbedLayout } from "../../../src/layouts/TabbedLayout"; +import CippFormPage from "../../../src/components/CippFormPages/CippFormPage"; + +const tabOptions = [ + { label: "SAM App Roles", path: "/cipp/sam-roles" }, + { label: "SSO", path: "/cipp/sso" }, +]; + +const Harness = (formPageProps) => { + const formControl = useForm({ mode: "onChange" }); return ( - - + - - - ) -} + postUrl="/api/x" + queryKey="x" + {...formPageProps} + > +
    form content
    +
    + + ); +}; -describe('CippFormPage', () => { +describe("CippFormPage title vs the mobile tab picker", () => { beforeEach(() => { - apiState.mutate = vi.fn() - }) - - it('renders the page type, title, and form children', () => { - renderWithProviders() + layoutState.isMobile = false; + routerState.pathname = "/cipp/sam-roles"; + }); - expect(screen.getByRole('heading', { name: 'Add - User' })).toBeInTheDocument() - expect(screen.getByRole('textbox', { name: 'Display Name' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Submit' })).toBeDisabled() - }) + // Same defect class as CippPageCard: the picker trigger already says "SAM App Roles" + // right above this h4, so the page opened with its own name printed twice in a row. + it("stands its title down when the picker already says it", () => { + layoutState.isMobile = true; + renderWithProviders(); - it('renders a custom page type and hides it on request', () => { - const { unmount } = renderWithProviders() - expect(screen.getByRole('heading', { name: 'Edit - User' })).toBeInTheDocument() - unmount() + expect(screen.getAllByText("SAM App Roles")).toHaveLength(1); + expect( + screen.queryByRole("heading", { level: 4, name: "SAM App Roles" }) + ).not.toBeInTheDocument(); + }); - renderWithProviders() - expect(screen.getByRole('heading', { name: 'User' })).toBeInTheDocument() - }) + // With the page-type prefix the rendered text is "Add - SAM App Roles", which is not what + // the picker says — so it still renders. + it("keeps a title the prefix makes different", () => { + layoutState.isMobile = true; + renderWithProviders(); - it('submits form values to postUrl and strips empty fields', async () => { - const user = userEvent.setup() - renderWithProviders() + expect( + screen.getByRole("heading", { level: 4, name: "Add - SAM App Roles" }) + ).toBeInTheDocument(); + }); - await user.type(screen.getByRole('textbox', { name: 'Display Name' }), 'John Doe') - const submit = screen.getByRole('button', { name: 'Submit' }) - await waitFor(() => { - expect(submit).toBeEnabled() - }) - await user.click(submit) + it("keeps its title on desktop", () => { + renderWithProviders(); - await waitFor(() => { - expect(apiState.mutate).toHaveBeenCalledTimes(1) - }) - // notes stayed '', removeEmpty drops it from the payload - expect(apiState.mutate).toHaveBeenCalledWith({ - url: '/api/AddUser', - data: { displayName: 'John Doe' }, - }) - }) -}) + expect(screen.getByRole("heading", { level: 4, name: "SAM App Roles" })).toBeInTheDocument(); + }); +}); diff --git a/tests/components/CippPdf/CippPdfPreview.test.jsx b/tests/components/CippPdf/CippPdfPreview.test.jsx new file mode 100644 index 000000000000..575499cace03 --- /dev/null +++ b/tests/components/CippPdf/CippPdfPreview.test.jsx @@ -0,0 +1,128 @@ +import React from 'react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { screen } from '@testing-library/react' +import { renderWithProviders } from '../../test-utils' + +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ isMobile: false })) +vi.mock('../../../src/hooks/use-breakpoint', () => ({ + useIsMobileLayout: () => layoutState.isMobile, + useIsTabletLayout: () => false, + useTableViewMode: () => 'table', +})) + +// Building a real PDF in jsdom is neither possible nor the point: what is under test is which +// branch renders and what it hands the user. Stable identities — a fresh object per call +// re-renders forever. +const pdfState = vi.hoisted(() => ({ + instance: { loading: false, error: null, url: 'blob:http://localhost/report-1', blob: { size: 1_572_864 } }, + viewerProps: null, +})) +vi.mock('@react-pdf/renderer', () => ({ + PDFViewer: (props) => { + pdfState.viewerProps = props + return
    {props.children}
    + }, + usePDF: () => [pdfState.instance], +})) + +import { CippPdfPreview } from '../../../src/components/CippPdf/CippPdfPreview' + +const doc =
    document
    + +const render = (props = {}) => + renderWithProviders( + + {doc} + + ) + +describe('CippPdfPreview', () => { + beforeEach(() => { + layoutState.isMobile = false + pdfState.viewerProps = null + pdfState.instance = { + loading: false, + error: null, + url: 'blob:http://localhost/report-1', + blob: { size: 1_572_864 }, + } + }) + + it('renders the embedded viewer on desktop', () => { + render() + expect(screen.getByTestId('pdf-viewer')).toBeInTheDocument() + expect(screen.getByTestId('report-doc')).toBeInTheDocument() + expect(screen.queryByRole('link', { name: /open report/i })).not.toBeInTheDocument() + }) + + // title/fileName/viewerKey are ours, not react-pdf's — forwarding them would land unknown + // attributes on the iframe and warn. + it('does not leak its own props onto the desktop viewer', () => { + render({ style: { border: 'none' }, showToolbar: true, showDownload: true }) + expect(pdfState.viewerProps).not.toHaveProperty('title') + expect(pdfState.viewerProps).not.toHaveProperty('fileName') + expect(pdfState.viewerProps).not.toHaveProperty('viewerKey') + expect(pdfState.viewerProps).not.toHaveProperty('showDownload') + expect(pdfState.viewerProps.showToolbar).toBe(true) + }) + + // iOS renders a PDF in an iframe as a fixed first-page preview that cannot be scrolled, so + // below md the document goes to the platform viewer instead of being embedded. + it('hands off to the platform viewer on mobile instead of embedding', () => { + layoutState.isMobile = true + render() + + expect(screen.queryByTestId('pdf-viewer')).not.toBeInTheDocument() + + const open = screen.getByRole('link', { name: /open report/i }) + expect(open).toHaveAttribute('href', 'blob:http://localhost/report-1') + expect(open).toHaveAttribute('target', '_blank') + // a real anchor, not window.open in a handler — that is what popup blockers stop + expect(open.tagName).toBe('A') + }) + + // Six of the eight hosts already put a Download in their dialog actions; showing one here + // as well is exactly the duplicate that appeared on a phone. + it('offers no download of its own by default', () => { + layoutState.isMobile = true + render() + + expect(screen.queryByRole('link', { name: /download/i })).not.toBeInTheDocument() + }) + + it('offers a download named after the report where the host has none', () => { + layoutState.isMobile = true + render({ showDownload: true }) + + const download = screen.getByRole('link', { name: /download/i }) + expect(download).toHaveAttribute('download', 'Executive_Report.pdf') + expect(download).toHaveAttribute('href', 'blob:http://localhost/report-1') + }) + + it('names the report and its size', () => { + layoutState.isMobile = true + render() + + expect(screen.getByText('Executive Report - Contoso')).toBeInTheDocument() + expect(screen.getByText(/1\.5 MB/)).toBeInTheDocument() + }) + + it('shows progress while the document is still building', () => { + layoutState.isMobile = true + pdfState.instance = { loading: true, error: null, url: null, blob: null } + render() + + expect(screen.getByRole('progressbar')).toBeInTheDocument() + expect(screen.queryByRole('link', { name: /open report/i })).not.toBeInTheDocument() + }) + + it('surfaces a generation failure rather than an empty frame', () => { + layoutState.isMobile = true + pdfState.instance = { loading: false, error: 'boom', url: null, blob: null } + render() + + expect(screen.getByText(/could not be generated/i)).toBeInTheDocument() + expect(screen.queryByRole('link', { name: /open report/i })).not.toBeInTheDocument() + }) +}) diff --git a/tests/components/CippPdf/ReportDialogActions.stories.jsx b/tests/components/CippPdf/ReportDialogActions.stories.jsx new file mode 100644 index 000000000000..ec80d2f4fec8 --- /dev/null +++ b/tests/components/CippPdf/ReportDialogActions.stories.jsx @@ -0,0 +1,68 @@ +import React from 'react' +import { within, waitFor, expect } from 'storybook/test' +import { Box, Button, DialogActions, Typography } from '@mui/material' +import { Download } from '@mui/icons-material' +import { shrinkToPhoneViewport } from '../../viewport' + +/** + * The report dialogs' action row, reproduced — the dialogs themselves need too much data to + * mount. Below md the caption and two buttons cannot share a line at 390px, so the row stacks; + * this holds the contract that the buttons then span the same width as each other. + */ +const ActionsRow = () => ( + + :not(style) ~ :not(style)': { ml: { xs: 0, md: 1 } }, + }} + > + + + Sections enabled: 7 of 9 + + + + + + +) + +export default { + title: 'Components/CippPdf/ReportDialogActions', + tags: ['autodocs'], +} + +export const StackedAtPhoneWidth = { + render: () => , + play: async ({ canvasElement, step }) => { + const onAPhone = await shrinkToPhoneViewport() + if (!onAPhone) return + const canvas = within(canvasElement) + const host = canvasElement.querySelector('[data-testid="actions-host"]') + + const primary = canvas.getByRole('button', { name: /download pdf/i }) + const secondary = canvas.getByRole('button', { name: /^close$/i }) + + await step('the two buttons share one width and one left edge', async () => { + await waitFor(() => { + const a = primary.getBoundingClientRect() + const b = secondary.getBoundingClientRect() + expect(Math.abs(a.width - b.width)).toBeLessThanOrEqual(1) + expect(Math.abs(a.left - b.left)).toBeLessThanOrEqual(1) + expect(Math.abs(a.right - b.right)).toBeLessThanOrEqual(1) + }) + }) + + await step('and nothing pushes the row wider than the screen', async () => { + await expect(host.scrollWidth).toBeLessThanOrEqual(host.clientWidth) + }) + }, +} diff --git a/tests/components/CippSettings/CippPermissionReport.test.jsx b/tests/components/CippSettings/CippPermissionReport.test.jsx new file mode 100644 index 000000000000..eb13775528d8 --- /dev/null +++ b/tests/components/CippSettings/CippPermissionReport.test.jsx @@ -0,0 +1,86 @@ +import React from "react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../test-utils"; + +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ isMobile: false })); +vi.mock("../../../src/hooks/use-breakpoint", async (importOriginal) => ({ + ...(await importOriginal()), + useIsMobileLayout: () => layoutState.isMobile, +})); + +// Stable identities — a fresh object per call re-renders forever +const idle = vi.hoisted(() => ({ + isSuccess: false, + isFetching: false, + isPending: false, + isError: false, + data: undefined, + mutate: () => {}, + reset: () => {}, + refetch: () => {}, +})); +vi.mock("../../../src/api/ApiCall", () => ({ + ApiGetCall: () => idle, + ApiPostCall: () => idle, + ApiGetCallWithPagination: () => ({ ...idle, fetchNextPage: () => {} }), +})); + +import { CippPermissionReport } from "../../../src/components/CippSettings/CippPermissionReport"; + +const renderReport = () => + renderWithProviders( {}} />); + +describe("CippPermissionReport report actions", () => { + beforeEach(() => { + layoutState.isMobile = false; + }); + + it("keeps the button row inline on desktop, with no FAB", () => { + renderReport(); + expect(screen.getByRole("button", { name: /export report/i })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /page actions/i })).not.toBeInTheDocument(); + }); + + // Three contained buttons stacked full-width at 390px read as a banner wall before any + // content — page-level utilities belong in the page-actions FAB sheet on mobile. + it("moves the buttons into the FAB sheet on mobile", async () => { + layoutState.isMobile = true; + const user = userEvent.setup(); + renderReport(); + + const fab = screen.getByRole("button", { name: /page actions/i }); + // not on the page until the sheet opens + expect(screen.queryByRole("button", { name: /export report/i })).not.toBeInTheDocument(); + + await user.click(fab); + expect(await screen.findByText("Report")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /export report/i })).toBeInTheDocument(); + expect(screen.getByText(/import report/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /paste report/i })).toBeInTheDocument(); + + // uniform with every other sheet action: list rows, not contained buttons in a sheet + expect(document.querySelector(".MuiDrawer-paper .MuiButton-contained")).toBeNull(); + expect( + screen.getByRole("button", { name: /export report/i }).classList.contains("MuiListItemButton-root") + ).toBe(true); + }); + + // The sheet sits at modal + 1 — if a row tap didn't close it, the export dialog would + // open UNDERNEATH it. ListItemButton is a div[role=button], which the close selector + // originally missed. + it("closes the sheet when a row opens its dialog", async () => { + layoutState.isMobile = true; + const user = userEvent.setup(); + renderReport(); + + await user.click(screen.getByRole("button", { name: /page actions/i })); + const exportRow = await screen.findByRole("button", { name: /export report/i }); + await user.click(exportRow); + + // keepMounted keeps rows in the DOM; closed means hidden + await vi.waitFor(() => expect(screen.getByText(/paste report/i)).not.toBeVisible()); + }); +}); diff --git a/tests/components/CippTable/CIPPTableToptoolbar.test.jsx b/tests/components/CippTable/CIPPTableToptoolbar.test.jsx index fcaa292b66f9..bfa93e89bb11 100644 --- a/tests/components/CippTable/CIPPTableToptoolbar.test.jsx +++ b/tests/components/CippTable/CIPPTableToptoolbar.test.jsx @@ -279,6 +279,96 @@ describe('CIPPTableToptoolbar - preset list refresh', () => { expect(screen.getByRole('button', { name: 'Filters (1)' })).toBeInTheDocument() }) + // Regression: the restore effect used to key on getRequestData.isFetching, re-arming its + // 100ms timer on every fetch settle (once per page of an auto-paginated load) and + // overwriting whatever the user had just applied with the persisted filter. + it('does not clobber a user filter applied after the persisted one was restored', async () => { + const user = userEvent.setup() + renderGraphTable({}, { + settings: settingsWith({ + persistFilters: true, + setLastUsedFilter: vi.fn(), + lastUsedFilters: { + '': { type: 'column', value: [{ id: 'department', value: 'IT' }], name: 'IT only' }, + }, + }), + }) + // persisted "IT only" lands first + await waitFor(() => { + expect(screen.getByText('1-2 of 2')).toBeInTheDocument() + }, { timeout: 5000 }) + + // user switches to the other preset + await user.click(screen.getByRole('button', { name: /Filters/ })) + await user.click(await screen.findByRole('menuitem', { name: 'Sales only' })) + await waitFor(() => { + expect(screen.getByText('1-1 of 1')).toBeInTheDocument() + }) + + // well past the restore timer: the persisted filter must not come back + await new Promise((resolve) => setTimeout(resolve, 400)) + expect(screen.getByText('1-1 of 1')).toBeInTheDocument() + }, 30000) + + it('syncs the search box when a global preset is applied and cleared', async () => { + const user = userEvent.setup() + renderGraphTable({ + filters: [{ filterName: 'Named Alice', value: 'alice', type: 'global' }], + }) + await screen.findByText('1-3 of 3') + + await user.click(screen.getByRole('button', { name: /Filters/ })) + await user.click(await screen.findByRole('menuitem', { name: 'Named Alice' })) + await waitFor(() => { + expect(screen.getByPlaceholderText('Search...')).toHaveValue('alice') + }) + + // tapping the active preset again clears the slot — and the box with it + await user.click(screen.getByRole('button', { name: /Filters/ })) + await user.click(await screen.findByRole('menuitem', { name: 'Named Alice' })) + await waitFor(() => { + expect(screen.getByPlaceholderText('Search...')).toHaveValue('') + }) + }, 30000) + + // filterList was state-initialised from the prop and never re-synced, so pages that + // compute `filters` asynchronously showed an empty preset list forever + it('picks up filters that arrive after the first render', async () => { + const user = userEvent.setup() + presetsResult = graphPresetResult + + const LateFilters = () => { + const [filters, setFilters] = React.useState([]) + return ( + <> + + + + ) + } + + renderWithProviders() + await screen.findByText('1-3 of 3') + + await user.click(screen.getByRole('button', { name: /Filters/ })) + expect(screen.queryByRole('menuitem', { name: 'IT only' })).toBeNull() + await user.keyboard('{Escape}') + + await user.click(screen.getByRole('button', { name: 'load filters' })) + await user.click(screen.getByRole('button', { name: /Filters/ })) + expect(await screen.findByRole('menuitem', { name: 'IT only' })).toBeInTheDocument() + // the fetched graph preset is not lost when the prop-driven list arrives + expect(screen.getByRole('menuitem', { name: 'Widget View' })).toBeInTheDocument() + }, 30000) + it('renaming an applied graph preset keeps it marked active', async () => { const user = userEvent.setup() renderGraphTable() @@ -298,3 +388,25 @@ describe('CIPPTableToptoolbar - preset list refresh', () => { }) }, 30000) }) + +describe('CIPPTableToptoolbar desktop export', () => { + it('Export menu carries the row exports and opens the API response viewer', async () => { + const user = userEvent.setup() + renderWithProviders( + + ) + await screen.findByText('Users') + + await user.click(screen.getByRole('button', { name: /Export/ })) + await screen.findByRole('menuitem', { name: 'Export to CSV' }) + expect(screen.getByRole('menuitem', { name: 'Export to PDF' })).toBeInTheDocument() + + await user.click(screen.getByRole('menuitem', { name: 'View API Response' })) + await screen.findByText('API Response') + }) +}) diff --git a/tests/components/CippTable/CippDataTable.test.jsx b/tests/components/CippTable/CippDataTable.test.jsx index bdc5023185bf..2b7c1dd21658 100644 --- a/tests/components/CippTable/CippDataTable.test.jsx +++ b/tests/components/CippTable/CippDataTable.test.jsx @@ -1,8 +1,10 @@ import React from 'react' -import { screen, waitFor } from '@testing-library/react' +import { screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { vi } from 'vitest' import { renderWithProviders } from '../../test-utils' import { CippDataTable } from '../../../src/components/CippTable/CippDataTable' +import { resetOverlayHistory } from '../../../src/utils/overlay-history' const basicData = [ { displayName: 'Alice Smith', mail: 'alice@contoso.com', department: 'IT', accountEnabled: true }, @@ -310,3 +312,727 @@ describe('CippDataTable', () => { expect(container.querySelector('table')).not.toBeNull() }) }) + +// A card shows a title, subtitle and a few chips/details — on pages that never configured +// an offCanvas the rest of the row used to be unreachable in card view. +describe('CippDataTable card view without an offCanvas', () => { + const wideData = [ + { + displayName: 'Alice Smith', + mail: 'alice@contoso.com', + department: 'IT', + jobTitle: 'Engineer', + city: 'Seattle', + country: 'US', + accountEnabled: true, + }, + ] + const columns = ['displayName', 'mail', 'department', 'jobTitle', 'city', 'country'] + + it('opens an extended-info drawer from a card tap showing every shown column', async () => { + const user = userEvent.setup() + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + await user.click(screen.getByText('Alice Smith')) + + // fields that never fit on the card are present in the drawer + await waitFor(() => expect(screen.getAllByText(/Engineer/).length).toBeGreaterThan(0)) + expect(screen.getAllByText(/Seattle/).length).toBeGreaterThan(0) + }) + + // The test-detail pages render their own drawer body (offCanvas.children) — prepending + // the generic property list on top of it repeated Risk/Status above a body that already + // presents them. + it('lets a custom drawer body own the drawer, without the generic property list', async () => { + const user = userEvent.setup() + renderWithProviders( +
    rich detail body
    , + }} + /> + ) + + await waitFor(() => + expect(screen.getByText('Applications do not have client secrets configured')).toBeInTheDocument() + ) + await user.click(screen.getByText('Applications do not have client secrets configured')) + + // scope to the drawer that holds the body — the toolbar's Edit Filters offcanvas is + // also a mounted .MuiDrawer-paper and sorts first in the DOM + const body = await screen.findByTestId('rich-body') + const drawer = body.closest('.MuiDrawer-paper') + expect(drawer.textContent).toContain('rich detail body') + // no generic property list stacked above the page's own body + expect(drawer.textContent).not.toMatch(/Risk/) + }) + + // Retired: the extended-info drawer's action buttons. Pages still carry `actions` in their + // offCanvas configs (the Users page spreads userActions in), and the config is spread onto + // the drawer — so the retirement has to survive the spread, not just the explicit prop. + it('keeps retired drawer actions out even when the page config carries them', async () => { + const user = userEvent.setup() + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + await user.click(screen.getByText('Alice Smith')) + + // drawer is open (property list rendered) but the actions block is gone + await waitFor(() => expect(screen.getAllByText(/alice@contoso.com/).length).toBeGreaterThan(0)) + expect(screen.queryByText('View User')).not.toBeInTheDocument() + }) + + it('formats fallback values the way their table cells do', async () => { + const user = userEvent.setup() + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + await user.click(screen.getByText('Alice Smith')) + + // 'text' mode would flatten the boolean to the string "Yes"; the cell renderer uses an icon. + // Anchored: unanchored, this would also pass on "notcontoso.com" — and CodeQL flags it. + await waitFor(() => expect(screen.getAllByText(/^contoso\.com$/).length).toBeGreaterThan(0)) + expect(screen.queryByText('Yes')).toBeNull() + }) + + it('spells out portal links instead of showing a bare icon', async () => { + const user = userEvent.setup() + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Contoso')).toBeInTheDocument()) + await user.click(screen.getByText('Contoso')) + + const link = await screen.findByRole('link', { name: /open portal/i }) + expect(link).toHaveAttribute('href', 'https://admin.cloud.microsoft/?delegatedOrg=contoso') + expect(link).toHaveAttribute('target', '_blank') + }) + + it('links portal values on the card itself, scheme-less ones included', async () => { + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Contoso')).toBeInTheDocument()) + // rendered on the card, without opening the drawer + const link = await screen.findByRole('link', { name: /open portal/i }) + expect(link).toHaveAttribute('href', 'https://contoso-admin.sharepoint.com') + }) + + it('merges the page offCanvas fields with the remaining visible columns', async () => { + const user = userEvent.setup() + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + await user.click(screen.getByText('Alice Smith')) + await waitFor(() => expect(screen.getByText('User Details')).toBeInTheDocument()) + + // curated fields present, and the ones it left out are appended rather than dropped + expect(screen.getAllByText(/^alice@contoso\.com$/).length).toBeGreaterThan(0) + expect(screen.getAllByText(/Engineer/).length).toBeGreaterThan(0) + expect(screen.getAllByText(/Seattle/).length).toBeGreaterThan(0) + }) + + it('does not repeat a field that appears in both lists', async () => { + const user = userEvent.setup() + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + await user.click(screen.getByText('Alice Smith')) + await waitFor(() => expect(screen.getByText('User Details')).toBeInTheDocument()) + + // scoped to the drawer — the card behind it renders its own Department row + const drawer = screen.getByText('User Details').closest('.MuiDrawer-paper') + expect(drawer).not.toBeNull() + expect(within(drawer).getAllByText('Department').length).toBe(1) + }) + + it('leaves a page-supplied offCanvas in charge', async () => { + const user = userEvent.setup() + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + await user.click(screen.getByText('Alice Smith')) + + // the page's own drawer opens — the fallback never substitutes for a configured one + await waitFor(() => expect(screen.getByText('User Details')).toBeInTheDocument()) + expect(screen.getAllByText(/Seattle/).length).toBeGreaterThan(0) + }) +}) + +// The offcanvas walks the rows with Prev/Next and reports "N of M". Both come from the +// table's row model, and both used to be read from a mirror of it kept in state. +describe('CippDataTable offcanvas row navigation', () => { + // Deliberately unsorted: the display order and the arrival order differ. + const people = [ + { displayName: 'Carol Williams', mail: 'carol@contoso.com' }, + { displayName: 'Alice Smith', mail: 'alice@contoso.com' }, + { displayName: 'Bob Johnson', mail: 'bob@contoso.com' }, + ] + + // The Prev/Next bar and the position caption only render below md. + const useMobileViewport = () => { + const cache = new Map() + window.matchMedia = (query) => { + if (!cache.has(query)) { + cache.set(query, { + matches: query.includes('max-width'), + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }) + } + return cache.get(query) + } + } + + const Table = (props) => ( + + ) + + // Rows land from the API after the table has already mounted — the normal case. + const AsyncTable = (props) => { + const [data, setData] = React.useState([]) + return ( + <> + + + + ) + } + + beforeEach(() => { + useMobileViewport() + }) + + afterEach(() => { + resetOverlayHistory() + delete window.matchMedia + }) + + it('counts rows that arrived after the table mounted', async () => { + const user = userEvent.setup() + renderWithProviders() + + await user.click(screen.getByRole('button', { name: 'Load rows' })) + await waitFor(() => expect(screen.getByText('Carol Williams')).toBeInTheDocument()) + await user.click(screen.getByText('Carol Williams')) + + expect(await screen.findByText('1 of 3')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /prev/i })).toBeDisabled() + expect(screen.getByRole('button', { name: /next/i })).toBeEnabled() + }) + + it('numbers rows in the order they are shown, not the order they arrived', async () => { + const user = userEvent.setup() + renderWithProviders( +
    + ) + + // Sorted, Carol is last on screen — so she is the last row, with nowhere to go next. + await waitFor(() => expect(screen.getByText('Carol Williams')).toBeInTheDocument()) + await user.click(screen.getByText('Carol Williams')) + + expect(await screen.findByText('3 of 3')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /next/i })).toBeDisabled() + }) + + it('counts only the rows left after a search', async () => { + const user = userEvent.setup() + const withTwoBobs = [ + ...people, + { displayName: 'Bob Marley', mail: 'bob.marley@contoso.com' }, + ] + renderWithProviders( +
    + ) + + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + await user.type(screen.getByRole('searchbox', { name: 'Search' }), 'bob') + await waitFor(() => expect(screen.queryByText('Alice Smith')).not.toBeInTheDocument()) + + await user.click(screen.getByText('Bob Marley')) + + // the sorted model is built from the FILTERED rows, so the search narrows the walk + // too: two Bobs, not four people. + expect(await screen.findByText('2 of 2')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /next/i })).toBeDisabled() + expect(screen.getByRole('button', { name: /prev/i })).toBeEnabled() + }) + + it('steps to the next row as displayed', async () => { + const user = userEvent.setup() + renderWithProviders( +
    + ) + + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + await user.click(screen.getByText('Alice Smith')) + expect(await screen.findByText('1 of 3')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: /next/i })) + + // Bob follows Alice on screen; Carol is where the raw arrival order would have landed. + expect(await screen.findByText('2 of 3')).toBeInTheDocument() + // scoped by the drawer's own heading — the toolbar renders a filter Drawer too + const drawer = screen.getByText('Extended Info').closest('.MuiDrawer-paper') + expect(within(drawer).getByText('bob@contoso.com')).toBeInTheDocument() + }) +}) + +// the narrow-table height measurement reads viewport-relative positions, so the toggle +// aligns the card surface with the scrolling ancestor's top before the table flips in +describe('CippDataTable cards->table toggle scroll', () => { + const useMobileViewport = () => { + const cache = new Map() + window.matchMedia = (query) => { + if (!cache.has(query)) { + cache.set(query, { + matches: query.includes('max-width'), + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }) + } + return cache.get(query) + } + } + + beforeEach(() => { + useMobileViewport() + }) + + afterEach(() => { + delete window.matchMedia + }) + + it('keeps a mid-page table in view instead of yanking the page to the top', async () => { + const user = userEvent.setup() + renderWithProviders( +
    + +
    + ) + await waitFor(() => expect(screen.getByTestId('cipp-card-view')).toBeInTheDocument()) + + // surface sits below the scroller's viewport top, page already scrolled + const scroller = screen.getByTestId('scroller') + const surface = screen.getByTestId('cipp-card-view') + scroller.getBoundingClientRect = () => ({ top: 64 }) + surface.getBoundingClientRect = () => ({ top: 300 }) + scroller.scrollTop = 120 + + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + + // prior scroll plus the surface's offset from the scroller viewport top + expect(scroller.scrollTop).toBe(120 + (300 - 64)) + }) +}) + +describe('CippDataTable subTables', () => { + const parentRows = [{ id: 'parent-1', displayName: 'Finance' }] + const relatedRows = [{ id: 'child-1', displayName: 'Jane Doe' }] + + it('injects a button column that opens a nested table', async () => { + const user = userEvent.setup() + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument()) + await user.click(screen.getByRole('button', { name: 'View' })) + + const dialog = await screen.findByRole('dialog') + await waitFor(() => { + expect(within(dialog).getByText('Related for Finance')).toBeInTheDocument() + }) + await waitFor(() => { + expect(within(dialog).getByText('Jane Doe')).toBeInTheDocument() + }) + }) + + it('runs nested row and bulk actions with the parent row attached', async () => { + const rowFn = vi.fn() + const user = userEvent.setup() + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument()) + await user.click(screen.getByRole('button', { name: 'View' })) + + const dialog = await screen.findByRole('dialog') + await waitFor(() => expect(within(dialog).getByText('Jane Doe')).toBeInTheDocument()) + + await user.click(within(dialog).getByRole('button', { name: 'Row actions' })) + await user.click(await screen.findByText('Remove')) + + expect(rowFn).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'child-1', + displayName: 'Jane Doe', + parent: expect.objectContaining({ id: 'parent-1', displayName: 'Finance' }), + }), + expect.anything(), + expect.anything() + ) + + rowFn.mockClear() + await user.click(within(dialog).getByRole('button', { name: 'Select' })) + await user.click(within(dialog).getByRole('checkbox', { name: 'Select Jane Doe' })) + await user.click(within(dialog).getByRole('button', { name: 'Actions' })) + await user.click(await screen.findByText('Remove')) + + expect(rowFn).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'child-1', + parent: expect.objectContaining({ id: 'parent-1' }), + }), + expect.anything(), + expect.anything() + ) + }) + + it('replaces a data column that shares the subTable id', async () => { + const user = userEvent.setup() + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument()) + await user.click(screen.getByRole('button', { name: 'View' })) + + const dialog = await screen.findByRole('dialog') + await waitFor(() => { + expect(within(dialog).getByText('Jane Doe')).toBeInTheDocument() + }) + expect(within(dialog).queryByText('stale')).not.toBeInTheDocument() + }) + + it('does not show a subTable column unless it is listed in simpleColumns', async () => { + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument()) + expect(screen.queryByRole('button', { name: 'View' })).not.toBeInTheDocument() + }) + + it('shows cachedColumn instead of the nested table button when that field is on the data', async () => { + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument()) + expect(screen.queryByRole('button', { name: 'View members' })).not.toBeInTheDocument() + expect(screen.getByText('Jane, Bob')).toBeInTheDocument() + }) + + it('renders cached report columns in table view without a stale column order crash', async () => { + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument()) + expect(screen.getByRole('columnheader', { name: 'Members' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'View members' })).not.toBeInTheDocument() + }) + + it('still shows the nested table button when cachedColumn is configured but missing from the data', async () => { + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument()) + expect(screen.getByRole('button', { name: 'View members' })).toBeInTheDocument() + }) + + it('shows the nested table button when cachedColumn exists but is empty (live API shape)', async () => { + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument()) + expect(screen.getByRole('button', { name: 'View members' })).toBeInTheDocument() + }) + + it('renders a declarative nested cardButton from table config', async () => { + const user = userEvent.setup() + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument()) + await user.click(screen.getByRole('button', { name: 'View' })) + + const nested = await screen.findByRole('dialog') + const addButton = await within(nested).findByRole('button', { name: 'Add Members' }) + await user.click(addButton) + + expect(await screen.findByText('Add Members for Finance?')).toBeInTheDocument() + }) +}) diff --git a/tests/components/CippTable/CippDataTableButton.stories.jsx b/tests/components/CippTable/CippDataTableButton.stories.jsx index 87a7fb897397..b348d2de5625 100644 --- a/tests/components/CippTable/CippDataTableButton.stories.jsx +++ b/tests/components/CippTable/CippDataTableButton.stories.jsx @@ -1,3 +1,4 @@ +import { http, HttpResponse } from 'msw' import { within, expect, userEvent, waitFor } from 'storybook/test' import CippDataTableButton from '../../../src/components/CippTable/CippDataTableButton' @@ -54,3 +55,52 @@ export const EmptyData = { data: null, }, } + +export const LiveNestedTable = { + parameters: { + msw: { + handlers: [ + http.get('/api/TestRelated', () => + HttpResponse.json({ + Results: [ + { id: 'rel-1', displayName: 'Related one' }, + { id: 'rel-2', displayName: 'Related two' }, + ], + }) + ), + http.post('/api/ExecTestRelated', () => HttpResponse.json({ Results: 'ok' })), + ], + }, + }, + args: { + row: { id: 'parent-1', displayName: 'Finance' }, + label: 'View', + title: 'Related for [displayName]', + queryKey: 'related-[id]', + api: { + url: '/api/TestRelated', + data: { someId: '[id]' }, + dataKey: 'Results', + }, + simpleColumns: ['displayName'], + actions: [ + { + label: 'Remove', + type: 'POST', + url: '/api/ExecTestRelated', + data: { childId: 'id', parentId: 'parent.id' }, + confirmText: 'Remove [displayName] from [parent.displayName]?', + }, + ], + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + await step('opens a live nested table on click', async () => { + await userEvent.click(canvas.getByRole('button', { name: 'View' })) + const root = within(document.body) + await waitFor(() => { + expect(root.getByRole('dialog')).toBeVisible() + }) + }) + }, +} diff --git a/tests/components/CippTable/CippDataTableButton.test.jsx b/tests/components/CippTable/CippDataTableButton.test.jsx index c42bbd76de0c..3a8172f07229 100644 --- a/tests/components/CippTable/CippDataTableButton.test.jsx +++ b/tests/components/CippTable/CippDataTableButton.test.jsx @@ -3,8 +3,22 @@ import { screen, within, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { renderWithProviders } from '../../test-utils' import CippDataTableButton from '../../../src/components/CippTable/CippDataTableButton' +import { ApiGetCallWithPagination } from '../../../src/api/ApiCall' +import { api, paginatedResult } from '../../mocks/api-call' + +vi.mock('../../../src/api/ApiCall', async () => (await import('../../mocks/api-call')).apiCallMock()) + +const idlePaginated = paginatedResult([], { isSuccess: false }) +const relatedRows = [{ id: 'rel-1', name: 'Related one' }] +const relatedResult = paginatedResult(relatedRows) describe('CippDataTableButton', () => { + beforeEach(() => { + ApiGetCallWithPagination.mockClear() + api.paginated = (opts) => + opts?.url === '/api/TestRelated' ? relatedResult : idlePaginated + }) + it('shows item count and opens dialog on click', async () => { const user = userEvent.setup() renderWithProviders( @@ -80,4 +94,57 @@ describe('CippDataTableButton', () => { expect(button).toHaveTextContent('No items') expect(button).toBeDisabled() }) + + it('does not fetch live related data until the button is clicked', async () => { + const user = userEvent.setup() + const parentRow = { id: 'parent-1', displayName: 'Finance' } + + renderWithProviders( + + ) + + expect(screen.getByRole('button', { name: 'View' })).toBeEnabled() + expect( + ApiGetCallWithPagination.mock.calls.some((call) => call[0]?.url === '/api/TestRelated') + ).toBe(false) + + await user.click(screen.getByRole('button', { name: 'View' })) + + const dialog = await screen.findByRole('dialog') + expect(dialog).toBeInTheDocument() + await waitFor(() => { + expect( + ApiGetCallWithPagination.mock.calls.some((call) => call[0]?.url === '/api/TestRelated') + ).toBe(true) + }) + + const relatedCall = ApiGetCallWithPagination.mock.calls.find( + (call) => call[0]?.url === '/api/TestRelated' + ) + expect(relatedCall[0].data.someId).toBe('parent-1') + expect(relatedCall[0].queryKey).toBe('related-parent-1') + }) + + it('disables the live button when condition is false', () => { + renderWithProviders( + row.id === 'other'} + api={{ url: '/api/TestRelated', dataKey: 'Results' }} + /> + ) + expect(screen.getByRole('button', { name: 'View' })).toBeDisabled() + }) }) diff --git a/tests/components/CippTable/CippDiagnosticsFilter.test.jsx b/tests/components/CippTable/CippDiagnosticsFilter.test.jsx new file mode 100644 index 000000000000..b12e89ea8492 --- /dev/null +++ b/tests/components/CippTable/CippDiagnosticsFilter.test.jsx @@ -0,0 +1,47 @@ +import React from 'react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { renderWithProviders } from '../../test-utils' +import CippDiagnosticsFilter from '../../../src/components/CippTable/CippDiagnosticsFilter' + +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ isMobile: false })) +vi.mock('../../../src/hooks/use-breakpoint', () => ({ + useIsMobileLayout: () => layoutState.isMobile, + useIsTabletLayout: () => false, + useTableViewMode: () => 'table', +})) + +// Stable identities: a fresh object per call changes on every render and spins a loop. +const idleGet = vi.hoisted(() => ({ data: [], isFetching: false, isSuccess: true })) +const idlePost = vi.hoisted(() => ({ mutate: () => {}, isPending: false })) +const idlePaginated = vi.hoisted(() => ({ data: undefined, isFetching: false })) +vi.mock('../../../src/api/ApiCall', () => ({ + ApiGetCall: () => idleGet, + ApiPostCall: () => idlePost, + ApiGetCallWithPagination: () => idlePaginated, +})) + +beforeEach(() => { + layoutState.isMobile = false +}) + +describe('CippDiagnosticsFilter', () => { + // `rows` is a DOM attribute, so this cannot come from a responsive sx value. + // MUI renders a hidden shadow textarea beside the real one; only the real one carries + // the rows attribute this test is about. + const queryBox = (container) => + Array.from(container.querySelectorAll('textarea')).find((el) => el.hasAttribute('rows')) + + it('shortens the KQL box on a phone', () => { + layoutState.isMobile = true + const { container } = renderWithProviders( {}} />) + + expect(queryBox(container)).toHaveAttribute('rows', '6') + }) + + it('keeps twelve rows on desktop', () => { + const { container } = renderWithProviders( {}} />) + + expect(queryBox(container)).toHaveAttribute('rows', '12') + }) +}) diff --git a/tests/components/CippTable/CippGraphExplorerFilter.test.jsx b/tests/components/CippTable/CippGraphExplorerFilter.test.jsx index 497382cdcaee..49df07503eb8 100644 --- a/tests/components/CippTable/CippGraphExplorerFilter.test.jsx +++ b/tests/components/CippTable/CippGraphExplorerFilter.test.jsx @@ -276,4 +276,48 @@ describe('CippGraphExplorerFilter', () => { expect(onSubmitFilter.mock.calls[0][0]).toEqual({ version: 'beta' }) }) }) + + // Seeding from endpointFilter moved out of the render body (it updated the subscribed + // Controller mid-render, which the browser reports as "Cannot update a component while + // rendering a different component"). These cover the behaviour that move had to preserve — + // the warning itself doesn't reproduce under jsdom, so it can't be asserted here. + describe('endpointFilter prop', () => { + it('seeds the endpoint field from the prop', async () => { + renderWithProviders( + + ) + + await waitFor(() => { + expect(screen.getByRole('textbox', { name: 'Endpoint' })).toHaveValue('users') + }) + }) + + it('submits the seeded endpoint', async () => { + const onSubmitFilter = vi.fn() + const user = userEvent.setup() + renderWithProviders( + + ) + await waitFor(() => { + expect(screen.getByRole('textbox', { name: 'Endpoint' })).toHaveValue('users') + }) + + await user.click(screen.getByRole('button', { name: 'Apply Filter' })) + await waitFor(() => { + expect(onSubmitFilter).toHaveBeenCalledTimes(1) + }) + expect(onSubmitFilter.mock.calls[0][0]).toMatchObject({ endpoint: 'users' }) + }) + + it('leaves the endpoint field empty when no endpointFilter is given', async () => { + renderWithProviders() + await waitFor(() => { + expect(screen.getByRole('textbox', { name: 'Endpoint' })).toHaveValue('') + }) + }) + }) }) diff --git a/tests/components/CippTable/CippMobileCardList.stories.jsx b/tests/components/CippTable/CippMobileCardList.stories.jsx new file mode 100644 index 000000000000..f23120ccf7b0 --- /dev/null +++ b/tests/components/CippTable/CippMobileCardList.stories.jsx @@ -0,0 +1,423 @@ +import React from 'react' +import { within, expect, userEvent, waitFor } from 'storybook/test' +import { Box, Button } from '@mui/material' +import { Add, Block, Delete, Edit } from '@mui/icons-material' +import { CippDataTable } from '../../../src/components/CippTable/CippDataTable' +import { SettingsProvider } from '../../../src/contexts/settings-context' +import { shrinkToPhoneViewport, growToDesktopViewport } from '../../viewport' + +// most stories force cards via the viewMode prop; TableViewToggle shrinks the real viewport instead, since the toggle needs no explicit prop +const users = [ + { + id: 'u-1', + displayName: 'Alice Smith', + userPrincipalName: 'alice@contoso.com', + mail: 'alice@contoso.com', + department: 'IT', + jobTitle: 'Engineer', + accountEnabled: true, + createdDateTime: '2024-01-15T10:30:00Z', + }, + { + id: 'u-2', + displayName: 'Bob Johnson', + userPrincipalName: 'bob@contoso.com', + mail: 'bob@contoso.com', + department: 'Sales', + jobTitle: 'Account Manager', + accountEnabled: true, + createdDateTime: '2024-03-22T14:15:00Z', + }, + { + id: 'u-3', + displayName: 'Carol Williams', + userPrincipalName: 'carol@contoso.com', + mail: 'carol@contoso.com', + department: 'IT', + jobTitle: 'Director', + accountEnabled: false, + createdDateTime: '2023-11-01T09:00:00Z', + }, +] + +const manyUsers = Array.from({ length: 120 }, (_, i) => ({ + id: `bulk-${i}`, + displayName: `User ${String(i).padStart(3, '0')}`, + userPrincipalName: `user${i}@contoso.com`, + mail: `user${i}@contoso.com`, + department: i % 2 ? 'Sales' : 'IT', + accountEnabled: i % 5 !== 0, +})) + +const simpleColumns = ['displayName', 'userPrincipalName', 'accountEnabled', 'department', 'jobTitle'] + +const actions = [ + { label: 'Edit user', icon: , link: '/identity/administration/users/edit?id=[id]' }, + { label: 'Block sign-in', icon: , type: 'POST', url: '/api/ExecDisableUser' }, + { label: 'Delete user', icon: , type: 'POST', url: '/api/RemoveUser', color: 'error' }, +] + +export default { + title: 'Components/CippTable/CippMobileCardList', + component: CippDataTable, + tags: ['autodocs'], + args: { + viewMode: 'cards', + maxHeightOffset: '100px', + }, + decorators: [ + (Story) => ( + + + + + + ), + ], +} + +export const Default = { + args: { + title: 'Users', + data: users, + simpleColumns, + actions, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step('one card per row, titled by the name column', async () => { + await waitFor(() => expect(canvas.getByText('Alice Smith')).toBeInTheDocument()) + expect(canvas.getByText('Carol Williams')).toBeInTheDocument() + // no
    in card view + expect(canvasElement.querySelector('table')).toBeNull() + }) + + await step('row kebab opens the action sheet with the page actions', async () => { + const kebabs = canvas.getAllByRole('button', { name: /row actions/i }) + await userEvent.click(kebabs[0]) + const body = within(document.body) + await waitFor(() => expect(body.getByText('Block sign-in')).toBeInTheDocument()) + expect(body.getByText('Delete user')).toBeInTheDocument() + await userEvent.keyboard('{Escape}') + await waitFor(() => expect(body.queryByRole('dialog')).toBeNull()) + }) + + await step('Filters opens the shared bottom sheet with the card fields', async () => { + const body = within(document.body) + await userEvent.click(canvas.getByRole('button', { name: 'Table options' })) + const filterSheet = await body.findByRole('dialog') + expect(within(filterSheet).getByText('Fields shown')).toBeInTheDocument() + await userEvent.keyboard('{Escape}') + await waitFor(() => expect(body.queryByRole('dialog')).toBeNull()) + }) + }, +} + +export const SelectMode = { + args: { + title: 'Users', + data: users, + simpleColumns, + actions, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + await waitFor(() => expect(canvas.getByText('Alice Smith')).toBeInTheDocument()) + + await step('Select reveals per-card checkboxes and the bulk bar', async () => { + await userEvent.click(canvas.getByRole('button', { name: /select/i })) + const checkboxes = await canvas.findAllByRole('checkbox') + await userEvent.click(checkboxes[0]) + await waitFor(() => expect(canvasElement.textContent).toContain('1 selected')) + }) + }, +} + +export const PageActionsFab = { + args: { + title: 'Users', + data: users, + simpleColumns, + cardButton: ( + + + + + ), + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + const body = within(document.body) + await waitFor(() => expect(canvas.getByText('Alice Smith')).toBeInTheDocument()) + + await step('cardButton children live behind the FAB', async () => { + await userEvent.click(body.getByRole('button', { name: 'Page actions' })) + await waitFor(() => expect(body.getByRole('button', { name: 'Add User' })).toBeInTheDocument()) + expect(body.getByRole('button', { name: 'Bulk Add' })).toBeInTheDocument() + }) + }, +} + +export const LoadMore = { + args: { + title: 'Users', + data: manyUsers, + simpleColumns: ['displayName', 'userPrincipalName', 'accountEnabled'], + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step('starts at the configured page size', async () => { + await waitFor(() => expect(canvasElement.textContent).toContain('Showing 25 of 120'), { + timeout: 10000, + }) + }) + + await step('Load more grows the same list rather than paging', async () => { + await userEvent.click(canvas.getByRole('button', { name: /load 50 more/i })) + await waitFor(() => expect(canvasElement.textContent).toContain('Showing 75 of 120')) + // still one continuous list — no pagination control appeared + expect(canvas.queryByRole('button', { name: /go to next page/i })).toBeNull() + }) + }, +} + +export const EmptyAfterFilter = { + args: { + title: 'Users', + data: users, + simpleColumns, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + await waitFor(() => expect(canvas.getByText('Alice Smith')).toBeInTheDocument()) + + await step('a search with no matches offers to clear filters', async () => { + await userEvent.type(canvas.getByPlaceholderText(/search/i), 'zzzzz') + await waitFor( + () => expect(canvas.getByRole('button', { name: /clear filters/i })).toBeInTheDocument(), + { timeout: 3000 } + ) + }) + }, +} + +// The pair that proves "one table instance, two presentations": same data, same filter, +// same resulting row set — only the presentation differs. +const FILTERED_DEPARTMENT = 'IT' + +// The search box is debounced 200ms, so the filter landing is observed by waiting for the +// excluded row to disappear — the included rows are on screen before the filter applies. +const applyDepartmentSearch = async (canvas) => { + await userEvent.type(canvas.getByPlaceholderText(/search/i), FILTERED_DEPARTMENT) + await waitFor(() => expect(canvas.queryByText('Bob Johnson')).toBeNull(), { timeout: 5000 }) + return [canvas.getByText('Alice Smith'), canvas.getByText('Carol Williams')] +} + +export const DesktopTable = { + args: { + title: 'Users', + viewMode: 'table', + data: users, + simpleColumns, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step('table view lists exactly the IT users', async () => { + expect(canvasElement.querySelector('table')).not.toBeNull() + const matched = await applyDepartmentSearch(canvas) + expect(matched).toHaveLength(2) + }) + }, +} + +export const MobileCards = { + args: { + title: 'Users', + viewMode: 'cards', + data: users, + simpleColumns, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step('card view yields the identical row set from the same state', async () => { + expect(canvasElement.querySelector('table')).toBeNull() + const matched = await applyDepartmentSearch(canvas) + expect(matched).toHaveLength(2) + }) + }, +} + +export const TableViewToggle = { + render: () => ( + + + + ), + play: async ({ canvasElement }) => { + // shrink for real: a viewMode prop would also force cards, but hides the toggle (precedence rule) + const onAPhone = await shrinkToPhoneViewport() + const canvas = within(canvasElement) + await canvas.findByText('Alice Smith') + if (!onAPhone) { + return + } + + // 'Alice Smith' renders in both branches, so the card list itself has to settle + await waitFor(() => expect(canvas.getByTestId('cipp-mobile-card-list')).toBeInTheDocument()) + + await userEvent.click(await canvas.findByRole('button', { name: 'Toggle table view' })) + await waitFor(() => { + expect(canvasElement.querySelector('table')).not.toBeNull() + expect(canvas.queryByTestId('cipp-mobile-card-list')).toBeNull() + }) + + // real MRT table mounts with the page's configured columns + await waitFor(() => expect(canvas.getAllByRole('columnheader').length).toBeGreaterThan(0)) + const headerText = canvas.getAllByRole('columnheader').map((cell) => cell.textContent) + expect(headerText.some((text) => text.includes('Display Name'))).toBe(true) + + // transient: the toggle never persists + const persisted = JSON.parse(window.localStorage.getItem('app.settings')) + expect(persisted.tableViewMode).toBe('auto') + + // phone table bar: kebab opens the shared sheet, which carries refresh. + // MUI's Tooltip stamps the 'Refresh data' aria-label onto the wrapping span, so that's + // the queryable anchor for the desktop refresh button (the IconButton has no name of its own) + expect(canvasElement.querySelector('[aria-label="Refresh data"]')).toBeNull() + const optionsButton = canvas.getByRole('button', { name: 'Table options' }) + await userEvent.click(optionsButton) + const filterSheet = await within(document.body).findByRole('dialog') + expect(within(filterSheet).getByText('Fields shown')).toBeInTheDocument() + expect(within(filterSheet).getByText('Reset all filters')).toBeInTheDocument() + expect(within(filterSheet).getByText('Refresh data')).toBeInTheDocument() + // the sheet owns page size on phones, current size marked active + expect(within(filterSheet).getByText('Rows per page')).toBeInTheDocument() + const activeSize = within(filterSheet).getByText('25').closest('.MuiChip-root') + expect(activeSize.className).toContain('MuiChip-filled') + await userEvent.keyboard('{Escape}') + await waitFor(() => expect(within(document.body).queryByRole('dialog')).toBeNull()) + + // same aria-label, now the desktop toolbar's "way back" button + await userEvent.click(canvas.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(canvas.getByTestId('cipp-mobile-card-list')).toBeInTheDocument()) + }, +} + +export const TableViewToggleWithActions = { + // render ignores the meta's default args (viewMode: 'cards' would hide the toggle button) + render: () => ( + + + + + } + /> + + ), + play: async ({ canvasElement }) => { + const onAPhone = await shrinkToPhoneViewport() + const canvas = within(canvasElement) + const body = within(document.body) + await canvas.findByText('Alice Smith') + if (!onAPhone) { + return + } + + await waitFor(() => expect(canvas.getByTestId('cipp-mobile-card-list')).toBeInTheDocument()) + + await userEvent.click(await canvas.findByRole('button', { name: 'Toggle table view' })) + await waitFor(() => { + expect(canvasElement.querySelector('table')).not.toBeNull() + expect(canvas.queryByTestId('cipp-mobile-card-list')).toBeNull() + }) + + // narrow table view: cardButton lives behind the page actions FAB, absent from the canvas until opened + expect(canvas.queryByRole('button', { name: 'Add User' })).toBeNull() + const fab = await body.findByRole('button', { name: 'Page actions' }) + + await userEvent.click(fab) + await waitFor(() => expect(body.getByRole('button', { name: 'Add User' })).toBeInTheDocument()) + }, +} + +export const TableViewToggleBulkActionsInHeader = { + // render ignores the meta's default args, same reason as the sibling toggle stories + render: () => ( + + + + ), + play: async ({ canvasElement }) => { + const onAPhone = await shrinkToPhoneViewport() + const canvas = within(canvasElement) + await canvas.findByText('Alice Smith') + if (!onAPhone) { + return + } + + await waitFor(() => expect(canvas.getByTestId('cipp-mobile-card-list')).toBeInTheDocument()) + await userEvent.click(await canvas.findByRole('button', { name: 'Toggle table view' })) + await waitFor(() => { + expect(canvasElement.querySelector('table')).not.toBeNull() + expect(canvas.queryByTestId('cipp-mobile-card-list')).toBeNull() + }) + + const firstRow = await waitFor(() => { + const row = canvasElement.querySelector('tbody tr') + expect(row).not.toBeNull() + return row + }) + await userEvent.click(within(firstRow).getByRole('checkbox')) + + // the mostly-empty header row is where the narrow toolbar's selection UI lands + const header = canvasElement.querySelector('.MuiCardHeader-root') + await waitFor(() => { + expect(within(header).getByText(/rows selected/)).toBeInTheDocument() + expect(within(header).getByRole('button', { name: 'Bulk Actions' })).toBeInTheDocument() + }) + // exactly one Bulk Actions button on screen — it moved, it did not duplicate + expect(canvas.getAllByRole('button', { name: 'Bulk Actions' })).toHaveLength(1) + + await userEvent.click(within(header).getByRole('button', { name: 'Bulk Actions' })) + const body = within(document.body) + await waitFor(() => expect(body.getByText('Delete user')).toBeInTheDocument()) + await userEvent.keyboard('{Escape}') + }, +} + +export const DesktopBulkActionsStayInToolbar = { + args: { + title: 'Users', + viewMode: 'table', + data: users, + simpleColumns, + actions, + }, + play: async ({ canvasElement }) => { + await growToDesktopViewport() + const canvas = within(canvasElement) + await waitFor(() => expect(canvasElement.querySelector('table')).not.toBeNull()) + await canvas.findByText('Alice Smith') + + const firstRow = canvasElement.querySelector('tbody tr') + await userEvent.click(within(firstRow).getByRole('checkbox')) + + await waitFor(() => expect(canvas.getByRole('button', { name: 'Bulk Actions' })).toBeInTheDocument()) + // the header exists (title-only, no cardButton on this story) but never received the portal + const header = canvasElement.querySelector('.MuiCardHeader-root') + expect(within(header).queryByRole('button', { name: 'Bulk Actions' })).toBeNull() + }, +} diff --git a/tests/components/CippTable/CippMobileCardList.test.jsx b/tests/components/CippTable/CippMobileCardList.test.jsx new file mode 100644 index 000000000000..32d26d258999 --- /dev/null +++ b/tests/components/CippTable/CippMobileCardList.test.jsx @@ -0,0 +1,368 @@ +import React from 'react' +import { vi } from 'vitest' +import { screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Button } from '@mui/material' +import { renderWithProviders, settingsWith } from '../../test-utils' + +// jsdom matchMedia never matches, so this overrides only useIsNarrowForTables for the FAB pivot, useTableViewMode stays real +const narrowState = vi.hoisted(() => ({ narrow: false })) +vi.mock('../../../src/hooks/use-breakpoint', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useIsNarrowForTables: () => narrowState.narrow } +}) + +import { CippDataTable } from '../../../src/components/CippTable/CippDataTable' + +// wide enough that full mode overflows into "+N more fields" +const users = [ + { + displayName: 'Alice Smith', + userPrincipalName: 'alice@contoso.com', + department: 'IT', + jobTitle: 'Engineer', + city: 'Seattle', + country: 'US', + accountEnabled: true, + }, +] +const columns = [ + 'displayName', + 'userPrincipalName', + 'department', + 'jobTitle', + 'city', + 'country', + 'accountEnabled', +] + +// no viewMode prop, so settings.tableViewMode='cards' forces cards but leaves the toggle allowed +const renderCards = (settings = {}, componentProps = {}) => + renderWithProviders( + , + { settings: settingsWith({ tableViewMode: 'cards', ...settings }) } + ) + +describe('CippMobileCardList card anatomy', () => { + it('shows the slotted anatomy: overflow counter, secondary slot as bare text', async () => { + renderCards() + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + // details cap at 3 of the 4 remaining columns -> 1 overflow + expect(screen.getByText(/more field/)).toBeInTheDocument() + // secondary slot is bare text, its column label never renders + expect(screen.queryByText('User Principal Name')).not.toBeInTheDocument() + }) +}) + +describe('CippMobileCardList status chips', () => { + // The identity/device/custom test tables: Result went to the chips row but Risk fell to + // the detail rows — two chips organised by two different systems on one card, and the + // detail-grid "High" said nothing about what was high. + it('keeps Result and Risk together in the chips row, and labels the mute one', async () => { + renderWithProviders( + , + { settings: settingsWith({ tableViewMode: 'cards' }) } + ) + await waitFor(() => + expect(screen.getByText('Tenant has M365 Copilot prerequisites')).toBeInTheDocument() + ) + + const passed = screen.getByText('Passed') + const high = screen.getByText('High') + // both chips share one container — Risk is not off in the details grid + expect(high.closest('.MuiStack-root')).toBe(passed.closest('.MuiStack-root')) + // "High" alone doesn't say what is high; "Passed" speaks for itself + expect(screen.getByText('Risk')).toBeInTheDocument() + expect(screen.queryByText('Result')).not.toBeInTheDocument() + }) +}) + +describe('CippMobileCardList table view toggle', () => { + afterEach(() => { + narrowState.narrow = false + }) + + it('opens the table view and the way back restores cards, never touching settings', async () => { + // narrow viewport: the round trip must hand back the card view intact + narrowState.narrow = true + const user = userEvent.setup() + const handleUpdate = vi.fn() + const { container } = renderCards({ handleUpdate }) + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + expect(screen.getByTestId('cipp-mobile-card-list')).toBeInTheDocument() + expect(screen.getByText('Department')).toBeInTheDocument() + expect(screen.getByText(/more field/)).toBeInTheDocument() + + // jsdom renders no MRT header/row text, so just check the table mounts and cards unmount + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(container.querySelector('table')).not.toBeNull()) + expect(screen.queryByTestId('cipp-mobile-card-list')).not.toBeInTheDocument() + + // same aria-label, now the desktop toolbar's "way back" button + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(screen.getByTestId('cipp-mobile-card-list')).toBeInTheDocument()) + + // full card content is back: detail rows and the overflow counter + expect(screen.getByText('Department')).toBeInTheDocument() + expect(screen.getByText(/more field/)).toBeInTheDocument() + + // transient: the view toggle never persists + expect(handleUpdate).not.toHaveBeenCalled() + }) + + it('the toggled table keeps every configured column visible', async () => { + narrowState.narrow = true + const user = userEvent.setup() + renderCards() + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Columns' })).toBeInTheDocument()) + await user.click(screen.getByRole('button', { name: 'Columns' })) + + // Columns menu reads table.getAllColumns(), unaffected by the virtualized header row + const menu = within(screen.getAllByRole('menu')[0]) + const checkbox = (name) => within(menu.getByRole('menuitem', { name })).getByRole('checkbox') + for (const name of [ + 'Display Name', + 'User Principal Name', + 'Account Enabled', + 'Department', + 'Job Title', + 'City', + 'Country', + ]) { + expect(checkbox(name)).toBeChecked() + } + }) + + it('a Fields shown toggle in the shared filter sheet changes what the card renders', async () => { + const user = userEvent.setup() + renderCards() + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + // department is a detail row on the card before the toggle + expect(screen.getByText('Department')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Table options' })) + await screen.findByText('Fields shown') + // the sheet is a portal appended to body, so its entry sorts after the card's label + await user.click(screen.getAllByText('Department').at(-1)) + await user.click(screen.getByRole('button', { name: 'Done' })) + + await waitFor(() => expect(screen.queryByText('Department')).not.toBeInTheDocument()) + }) + + // "Fields shown" is a checkbox per column — a dozen rows on a wide table — so anything + // after it starts a long scroll down. The table utilities (refresh, export, reset) are what + // people open this sheet for far more often than field toggles. + it('puts the table utilities above the Fields shown list, not below it', async () => { + const user = userEvent.setup() + renderCards() + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + + await user.click(screen.getByRole('button', { name: 'Table options' })) + const fields = await screen.findByText('Fields shown') + const refresh = screen.getByText('Refresh data') + + // DOCUMENT_POSITION_FOLLOWING = 4: fields comes after refresh in the DOM + expect(refresh.compareDocumentPosition(fields) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(4) + expect( + screen.getByText('Reset all filters').compareDocumentPosition(fields) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBe(4) + }) + + it('an explicit viewMode prop hides the toggle button', async () => { + renderWithProviders( + , + { settings: settingsWith() } + ) + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + expect(screen.queryByRole('button', { name: 'Toggle table view' })).not.toBeInTheDocument() + }) + + // Regression: the cards branch and the table branch are two alternating CIPPTableToptoolbar + // instances (only one mounts at a time), so activeFilters/searchValue/restoredFiltersRef used + // to live in the toolbar's own useState and reset on every flip. The table-branch kebab is + // unreachable here (mdDown from useMediaQuery never matches in jsdom, and useCompactMode stays + // false since offsetWidth/scrollWidth are always 0) — reopening the sheet after the round trip + // is the observable proxy for "state survived the two remounts". + it('an applied preset and its badge survive a flip to table and back', async () => { + narrowState.narrow = true + const user = userEvent.setup() + const presetFilters = [ + { filterName: 'IT department', value: [{ id: 'department', value: 'IT' }], type: 'column' }, + ] + renderCards({}, { filters: presetFilters }) + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + + await user.click(screen.getByRole('button', { name: 'Table options' })) + const sheet = await screen.findByRole('dialog') + await user.click(within(sheet).getByText('IT department')) + await user.click(within(sheet).getByRole('button', { name: 'Done' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + + // preset active before the flip — the sheet's aria-hidden overlay is gone now + await waitFor(() => { + expect(within(screen.getByRole('button', { name: 'Table options' })).getByText('1')).toBeInTheDocument() + }) + + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(document.querySelector('table')).not.toBeNull()) + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(screen.getByTestId('cipp-mobile-card-list')).toBeInTheDocument()) + + // badge count survived both remounts + expect(within(screen.getByRole('button', { name: 'Table options' })).getByText('1')).toBeInTheDocument() + + // the preset chip is marked active too + await user.click(screen.getByRole('button', { name: 'Table options' })) + const reopened = await screen.findByRole('dialog') + const chip = within(reopened).getByText('IT department').closest('.MuiChip-root') + expect(chip.className).toContain('MuiChip-filled') + }, 15000) + + it('a manual field-visibility change survives a flip, even with preferred columns saved for the page', async () => { + narrowState.narrow = true + const user = userEvent.setup() + // router mock resolves pageName to '' in tests, matching CIPPTableToptoolbar.test.jsx's convention + const allColumnsVisible = Object.fromEntries(columns.map((c) => [c, true])) + renderCards({ columnDefaults: { '': allColumnsVisible } }) + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + expect(screen.getByText('Department')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Table options' })) + await screen.findByText('Fields shown') + await user.click(screen.getAllByText('Department').at(-1)) + await user.click(screen.getByRole('button', { name: 'Done' })) + await waitFor(() => expect(screen.queryByText('Department')).not.toBeInTheDocument()) + + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(document.querySelector('table')).not.toBeNull()) + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(screen.getByTestId('cipp-mobile-card-list')).toBeInTheDocument()) + + // the manual hide must not be reverted by the saved preferred-columns set on remount + expect(screen.queryByText('Department')).not.toBeInTheDocument() + }, 15000) +}) + +describe('CippMobileCardList table-view page actions FAB', () => { + afterEach(() => { + narrowState.narrow = false + }) + + it('narrow viewport moves cardButton into the actions FAB once toggled to table view', async () => { + narrowState.narrow = true + const user = userEvent.setup() + renderCards({}, { cardButton: }) + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Page actions' })).toBeInTheDocument()) + + // action content stays behind the FAB until opened + expect(screen.queryByRole('button', { name: 'Add user' })).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Page actions' })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Add user' })).toBeInTheDocument()) + }) + + it('desktop viewport keeps cardButton in the header, no FAB', async () => { + const user = userEvent.setup() + renderCards({}, { cardButton: }) + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Add user' })).toBeInTheDocument()) + expect(screen.queryByRole('button', { name: 'Page actions' })).not.toBeInTheDocument() + }) +}) + +// The Card header hosts a portal target for the toolbar's bulk-actions UI on narrow +// viewports (CIPPTableToptoolbar's bulkActionsSlot). Row selection itself can't be driven +// here: CippDataTable's table renders with enableRowVirtualization + enableColumnVirtualization +// always on, and jsdom never reports a nonzero container size, so react-virtual computes an +// empty range — thead and tbody both mount with zero cells (verified: no checkboxes, no +// columnheaders, table-view page actions FAB tests above only ever check for the
    +// element itself, never header/row content). Selecting a row to exercise the portal is +// covered in the CippMobileCardList.stories.jsx browser story instead. +describe('CippMobileCardList table-view header mounts as the bulk-actions portal target', () => { + afterEach(() => { + narrowState.narrow = false + }) + + it('narrow + hideTitle + cardButton: header still mounts even though the FAB owns cardButton', async () => { + narrowState.narrow = true + const user = userEvent.setup() + const { container } = renderCards( + {}, + { hideTitle: true, cardButton: } + ) + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(container.querySelector('table')).not.toBeNull()) + + // headerAction is undefined here (FAB owns cardButton), so the gate has to key off + // cardButton directly or this mounts nothing and the portal target never exists + expect(container.querySelector('.MuiCardHeader-root')).not.toBeNull() + }) + + it('desktop + hideTitle + cardButton: header mounts with cardButton in it, same as before', async () => { + const user = userEvent.setup() + const { container } = renderCards( + {}, + { hideTitle: true, cardButton: } + ) + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(container.querySelector('table')).not.toBeNull()) + + const header = container.querySelector('.MuiCardHeader-root') + expect(header).not.toBeNull() + expect(within(header).getByRole('button', { name: 'Add user' })).toBeInTheDocument() + }) +}) + +describe('CippMobileCardList data source controls', () => { + afterEach(() => { + narrowState.narrow = false + }) + + it('renders in the Table options sheet, not in the page actions FAB', async () => { + narrowState.narrow = true + const user = userEvent.setup() + renderCards( + {}, + { dataSourceControls: Live badge, cardButton: } + ) + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + + await user.click(screen.getByRole('button', { name: 'Table options' })) + const filterSheet = await within(document.body).findByRole('dialog') + expect(within(filterSheet).getByText('Data source')).toBeInTheDocument() + expect(within(filterSheet).getByText('Live badge')).toBeInTheDocument() + + await user.click(within(filterSheet).getByRole('button', { name: 'Done' })) + await waitFor(() => expect(within(document.body).queryByRole('dialog')).not.toBeInTheDocument()) + + // narrow + table view: cardButton lives behind the FAB, dataSourceControls must not follow it there + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Page actions' })).toBeInTheDocument()) + + // and the table's card header must not double-render them (sheet is the only narrow home) + expect(screen.queryByText('Live badge')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Page actions' })) + const fabSheet = await within(document.body).findByRole('dialog') + await waitFor(() => expect(within(fabSheet).getByRole('button', { name: 'Add user' })).toBeInTheDocument()) + expect(within(fabSheet).queryByText('Live badge')).not.toBeInTheDocument() + }) +}) diff --git a/tests/components/CippTable/CippQueueTracker.stories.jsx b/tests/components/CippTable/CippQueueTracker.stories.jsx index 2c6ecf62b631..077059ccad5d 100644 --- a/tests/components/CippTable/CippQueueTracker.stories.jsx +++ b/tests/components/CippTable/CippQueueTracker.stories.jsx @@ -1,193 +1,87 @@ -import { fn, within, expect, userEvent, waitFor } from 'storybook/test' +import React from 'react' import { http, HttpResponse } from 'msw' +import { within, userEvent, waitFor, expect } from 'storybook/test' import { CippQueueTracker } from '../../../src/components/CippTable/CippQueueTracker' +import { shrinkToPhoneViewport } from '../../viewport' -const queueResponses = { - 'test-queue-running': { - PartitionKey: 'CippQueue', - RowKey: 'test-queue-running', - Name: 'Processing Users', - Status: 'Running', - TotalTasks: 10, - CompletedTasks: 6, - RunningTasks: 1, - FailedTasks: 0, - PercentComplete: 60.0, - PercentFailed: 0, - PercentRunning: 10.0, - Timestamp: '2026-04-08T10:00:00Z', - Tasks: [ - { Name: 'Process user 1', Status: 'Completed', Timestamp: '2026-04-08T10:00:01Z' }, - { Name: 'Process user 2', Status: 'Completed', Timestamp: '2026-04-08T10:00:02Z' }, - { Name: 'Process user 3', Status: 'Completed', Timestamp: '2026-04-08T10:00:03Z' }, - { Name: 'Process user 4', Status: 'Completed', Timestamp: '2026-04-08T10:00:04Z' }, - { Name: 'Process user 5', Status: 'Completed', Timestamp: '2026-04-08T10:00:05Z' }, - { Name: 'Process user 6', Status: 'Completed', Timestamp: '2026-04-08T10:00:06Z' }, - { Name: 'Process user 7', Status: 'Running', Timestamp: '2026-04-08T10:00:07Z' }, - { Name: 'Process user 8', Status: 'Pending', Timestamp: '2026-04-08T10:00:08Z' }, - { Name: 'Process user 9', Status: 'Pending', Timestamp: '2026-04-08T10:00:09Z' }, - { Name: 'Process user 10', Status: 'Pending', Timestamp: '2026-04-08T10:00:10Z' }, - ], - }, - 'test-queue-done': { - PartitionKey: 'CippQueue', - RowKey: 'test-queue-done', - Name: 'User Processing', - Status: 'Completed', - TotalTasks: 5, - CompletedTasks: 5, - RunningTasks: 0, - FailedTasks: 0, - PercentComplete: 100.0, - PercentFailed: 0, - PercentRunning: 0, - Timestamp: '2026-04-08T10:00:00Z', - Tasks: [ - { Name: 'Process user 1', Status: 'Completed', Timestamp: '2026-04-08T10:00:01Z' }, - { Name: 'Process user 2', Status: 'Completed', Timestamp: '2026-04-08T10:00:02Z' }, - { Name: 'Process user 3', Status: 'Completed', Timestamp: '2026-04-08T10:00:03Z' }, - { Name: 'Process user 4', Status: 'Completed', Timestamp: '2026-04-08T10:00:04Z' }, - { Name: 'Process user 5', Status: 'Completed', Timestamp: '2026-04-08T10:00:05Z' }, - ], - }, - 'test-queue-failed': { - PartitionKey: 'CippQueue', - RowKey: 'test-queue-failed', - Name: 'Failed Operation', - Status: 'Failed', - TotalTasks: 5, - CompletedTasks: 2, - RunningTasks: 0, - FailedTasks: 1, - PercentComplete: 40.0, - PercentFailed: 20.0, - PercentRunning: 0, - Timestamp: '2026-04-08T10:00:00Z', - Tasks: [ - { Name: 'Process user 1', Status: 'Completed', Timestamp: '2026-04-08T10:00:01Z' }, - { Name: 'Process user 2', Status: 'Completed', Timestamp: '2026-04-08T10:00:02Z' }, - { Name: 'Process user 3', Status: 'Failed', Timestamp: '2026-04-08T10:00:03Z' }, - { Name: 'Process user 4', Status: 'Pending', Timestamp: '2026-04-08T10:00:04Z' }, - { Name: 'Process user 5', Status: 'Pending', Timestamp: '2026-04-08T10:00:05Z' }, - ], - }, +// The task names are tenant default domains — one unbreakable token each, and the test +// tenants are the longest of them. +const queue = { + QueueId: 'q-1', + Name: 'Users (All Tenants)', + Status: 'Running', + PercentComplete: 20.3, + TotalTasks: 133, + CompletedTasks: 27, + RunningTasks: 4, + FailedTasks: 0, + Tasks: [ + { + Name: 'cyberdraintesttenant024.onmicrosoft.com', + Status: 'Completed', + Timestamp: '2026-08-12T23:15:33Z', + }, + { + Name: 'cyberdraintesttenant023.onmicrosoft.com', + Status: 'Running', + Timestamp: '2026-08-12T23:15:31Z', + }, + { + Name: 'cyberdraintesttenant022.onmicrosoft.com', + Status: 'Completed', + Timestamp: '2026-08-12T23:15:34Z', + }, + ], } -// Single handler that returns different data based on QueueId query param. -// Matches actual Invoke-ListCippQueue response shape. -const queueHandler = http.get('/api/ListCippQueue', ({ request }) => { - const url = new URL(request.url) - const queueId = url.searchParams.get('QueueId') - const data = queueResponses[queueId] - if (data) { - return HttpResponse.json([data]) - } - return HttpResponse.json([]) -}) +const handlers = [http.get('*/api/ListCippQueue', () => HttpResponse.json([queue]))] export default { title: 'Components/CippTable/CippQueueTracker', component: CippQueueTracker, tags: ['autodocs'], - args: { - onQueueComplete: fn(), - }, - beforeEach({ msw }) { - msw.use(queueHandler) - }, + parameters: { msw: { handlers } }, } -// Idle: no queueId, component renders nothing (returns null). -export const Idle = { - args: { - queueId: null, - queryKey: 'storybook-idle', - title: 'Queue Tracker', - }, +export const PhoneWidth = { + render: () => , play: async ({ canvasElement, step }) => { - await step('no queueId renders nothing', async () => { - // Component returns null when no queueId, canvas should be empty - await new Promise((r) => setTimeout(r, 500)) - expect(canvasElement.querySelector('button')).toBeNull() - }) - }, -} + const onAPhone = await shrinkToPhoneViewport() + const canvas = within(canvasElement) + const body = within(document.body) -export const InProgress = { - args: { - queueId: 'test-queue-running', - queryKey: 'storybook-running', - title: 'Processing Users', - }, - play: async ({ canvasElement, step }) => { - const root = within(document.body) - - await step('tracker button appears once queue data loads', async () => { - await waitFor(() => { - expect(canvasElement.querySelector('button')).not.toBeNull() - }) - await userEvent.click(canvasElement.querySelector('button')) + await step('the tracker opens the queue offcanvas', async () => { + const trigger = await canvas.findByRole('button') + await userEvent.click(trigger) + await waitFor(() => expect(body.getByText('Task Details')).toBeInTheDocument()) }) - await step('offcanvas shows running progress and the active task', async () => { - await waitFor(() => { - expect(root.getByText('Processing Users')).toBeVisible() - }) - expect(root.getByText(/60\.0%/)).toBeVisible() - expect(root.getByText('Process user 7')).toBeVisible() - }) - }, -} + if (!onAPhone) return -export const Completed = { - args: { - queueId: 'test-queue-done', - queryKey: 'storybook-done', - title: 'User Processing', - }, - play: async ({ canvasElement, args, step }) => { - const root = within(document.body) - - await step('open the tracker offcanvas', async () => { - await waitFor(() => { - expect(canvasElement.querySelector('button')).not.toBeNull() - }) - await userEvent.click(canvasElement.querySelector('button')) - }) - - await step('shows 100% and fires onQueueComplete', async () => { - await waitFor(() => { - expect(root.getByText('User Processing')).toBeVisible() - }) - expect(root.getByText(/100\.0%/)).toBeVisible() - await waitFor(() => { - expect(args.onQueueComplete).toHaveBeenCalled() - }) - }) - }, -} + // scope to one task card — statuses repeat across cards and in the stats row + const card = (name) => within(body.getByText(name).closest('.MuiBox-root')) -export const Failed = { - args: { - queueId: 'test-queue-failed', - queryKey: 'storybook-failed', - title: 'Failed Operation', - }, - play: async ({ canvasElement, step }) => { - const root = within(document.body) - - await step('open the tracker offcanvas', async () => { + await step('a full tenant domain does not push its status pill off the card', async () => { + const paper = body.getByText('Task Details').closest('.MuiDrawer-paper') + const pill = card('cyberdraintesttenant024.onmicrosoft.com').getByText(/^completed$/i) await waitFor(() => { - expect(canvasElement.querySelector('button')).not.toBeNull() + // the pill is intact inside the drawer, not clipped at its right edge + expect(pill.getBoundingClientRect().right).toBeLessThanOrEqual( + paper.getBoundingClientRect().right + ) + expect(paper.scrollWidth).toBeLessThanOrEqual(paper.clientWidth) }) - await userEvent.click(canvasElement.querySelector('button')) }) - await step('shows the failed operation and its failed task', async () => { - await waitFor(() => { - expect(root.getByText('Failed Operation')).toBeVisible() - }) - expect(root.getByText('Process user 3')).toBeVisible() + await step('and there is real space between the name and the pill', async () => { + const name = body.getByText('cyberdraintesttenant023.onmicrosoft.com') + const running = card('cyberdraintesttenant023.onmicrosoft.com').getByText(/^running$/i) + const nameBox = name.getBoundingClientRect() + const pillBox = running.getBoundingClientRect() + // either beside it with a gap, or wrapped below it — never overlapping + const besideWithGap = pillBox.left - nameBox.right >= 4 + const below = pillBox.top >= nameBox.bottom - 1 + await expect(besideWithGap || below).toBe(true) }) }, } diff --git a/tests/components/CippTable/order-columns-by-selection.test.js b/tests/components/CippTable/order-columns-by-selection.test.js new file mode 100644 index 000000000000..c5618a0c8cf0 --- /dev/null +++ b/tests/components/CippTable/order-columns-by-selection.test.js @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest' +import { orderColumnsBySelection } from '../../../src/components/CippTable/CippDataTable' + +// MRT reads initialState.columnOrder once; when the graph filter swaps the $select list +// after mount, new columns appended last — and the card view fills its three detail slots +// in column order, so the field the user just selected was the one overflowing into +// "+N more". Selection order has to win. +describe('orderColumnsBySelection', () => { + const all = ['displayName', 'userPrincipalName', 'mail', 'signInActivity.lastSuccessfulSignInDateTime', 'proxyAddresses'] + + it('puts the selection first, in selection order', () => { + expect( + orderColumnsBySelection(all, ['signInActivity.lastSuccessfulSignInDateTime', 'displayName']) + ).toEqual([ + 'signInActivity.lastSuccessfulSignInDateTime', + 'displayName', + 'userPrincipalName', + 'mail', + 'proxyAddresses', + ]) + }) + + it('ignores selected ids that have no column, keeps the rest stable', () => { + expect(orderColumnsBySelection(all, ['nope', 'mail'])).toEqual([ + 'mail', + 'displayName', + 'userPrincipalName', + 'signInActivity.lastSuccessfulSignInDateTime', + 'proxyAddresses', + ]) + }) + + it('is a no-op shape when nothing is selected', () => { + expect(orderColumnsBySelection(all, [])).toEqual(all) + }) +}) diff --git a/tests/components/CippTable/util-columnsFromAPI.test.jsx b/tests/components/CippTable/util-columnsFromAPI.test.jsx index 3840f4bd7b58..9023485aeb4f 100644 --- a/tests/components/CippTable/util-columnsFromAPI.test.jsx +++ b/tests/components/CippTable/util-columnsFromAPI.test.jsx @@ -13,6 +13,22 @@ describe('utilColumnsFromAPI', () => { expect(ids).toContain('department') }) + it('includes assigned license filter options found after the heuristic sample', () => { + const businessPremiumSku = 'cbdc14ab-d96c-4c30-b9f4-6ada7cdc1d46' + const data = Array.from({ length: 51 }, (_, index) => ({ + assignedLicenses: index === 50 ? [{ skuId: businessPremiumSku }] : [], + })) + + const licenseColumn = utilColumnsFromAPI(data).find( + (column) => column.id === 'assignedLicenses' + ) + + expect(licenseColumn.filterSelectOptions).toContainEqual({ + label: 'Microsoft 365 Business Premium', + value: businessPremiumSku, + }) + }) + it('generates columns for nested object properties', () => { const data = [ { info: { city: 'Seattle', state: 'WA' }, name: 'Test' }, diff --git a/tests/components/CippTable/util-mobile-card-slots.test.js b/tests/components/CippTable/util-mobile-card-slots.test.js new file mode 100644 index 000000000000..0a6ff5416cfa --- /dev/null +++ b/tests/components/CippTable/util-mobile-card-slots.test.js @@ -0,0 +1,146 @@ +import { getMobileCardSlots, isStatusLike } from '../../../src/components/CippTable/util-mobile-card-slots' + +// Shorthand column factory mirroring what table.getVisibleLeafColumns() yields +const col = (id, def = {}) => ({ id, columnDef: { id, ...def } }) +const bool = (id) => col(id, { sortingFn: 'boolean', filterVariant: 'select', filterSelectOptions: ['Yes', 'No'] }) + +const ids = (cols) => cols.map((c) => c.id) + +// The real /identity/administration/users simpleColumns, in page order. +// accountEnabled mirrors its explicit get-cipp-filter-variant case: select variant, +// alphanumeric sorting, NO options — only the STATUS_FIELDS id match can catch it. +const USERS_COLUMNS = [ + col('accountEnabled', { filterVariant: 'select', sortingFn: 'alphanumeric', filterFn: 'equals' }), + col('userPrincipalName'), + col('displayName'), + col('mail'), + col('businessPhones'), + col('proxyAddresses'), + col('assignedLicenses'), + col('licenseAssignmentStates'), + col('userType', { filterVariant: 'select', filterSelectOptions: ['Member', 'Guest'] }), +] + +describe('getMobileCardSlots', () => { + it('resolves the users page correctly — never titles cards "Yes"', () => { + const slots = getMobileCardSlots(USERS_COLUMNS) + expect(slots.primary.id).toBe('displayName') + expect(slots.secondary.id).toBe('userPrincipalName') + expect(ids(slots.chips)).toEqual(['accountEnabled', 'userType']) + expect(ids(slots.details)).toEqual(['mail', 'businessPhones', 'proxyAddresses']) + expect(ids(slots.rest)).toEqual(['assignedLicenses', 'licenseAssignmentStates']) + expect(slots.restCount).toBe(2) + }) + + it('filters out mrt-* utility columns', () => { + const slots = getMobileCardSlots([col('mrt-row-select'), col('displayName'), col('mrt-row-actions')]) + expect(slots.primary.id).toBe('displayName') + expect(slots.secondary).toBeNull() + expect(slots.restCount).toBe(0) + }) + + it('handles an empty column set', () => { + expect(getMobileCardSlots([])).toEqual({ + primary: null, + secondary: null, + chips: [], + details: [], + rest: [], + restCount: 0, + }) + expect(getMobileCardSlots(undefined).primary).toBeNull() + }) + + it('handles a single column', () => { + const slots = getMobileCardSlots([col('Tenant')]) + expect(slots.primary.id).toBe('Tenant') + expect(slots.secondary).toBeNull() + expect(slots.chips).toEqual([]) + expect(slots.details).toEqual([]) + }) + + it('falls back to first non-status textual column when nothing matches NAME_FIELDS', () => { + const slots = getMobileCardSlots([bool('isCompliant'), col('osVersion'), col('manufacturer')]) + expect(slots.primary.id).toBe('osVersion') + expect(ids(slots.chips)).toEqual(['isCompliant']) + expect(ids(slots.details)).toEqual(['manufacturer']) + }) + + it('falls back to the first column when everything is status-like', () => { + const slots = getMobileCardSlots([bool('enabled'), bool('isCompliant')]) + expect(slots.primary.id).toBe('enabled') + expect(ids(slots.chips)).toEqual(['isCompliant']) + }) + + it('caps chips at 3 and details at 3, remainder goes to rest', () => { + const slots = getMobileCardSlots([ + col('displayName'), + bool('a'), bool('b'), bool('c'), bool('d'), + col('e'), col('f'), col('g'), col('h'), + ]) + expect(ids(slots.chips)).toEqual(['a', 'b', 'c']) + // 'd' overflowed the chip cap — it flows into details ("whatever remains"), not rest + expect(ids(slots.details)).toEqual(['d', 'e', 'f']) + expect(ids(slots.rest)).toEqual(['g', 'h']) + }) + + it('respects mobileCard overrides for every slot', () => { + const slots = getMobileCardSlots(USERS_COLUMNS, { + primary: 'userPrincipalName', + secondary: 'mail', + chips: ['userType'], + details: ['assignedLicenses'], + }) + expect(slots.primary.id).toBe('userPrincipalName') + expect(slots.secondary.id).toBe('mail') + expect(ids(slots.chips)).toEqual(['userType']) + expect(ids(slots.details)).toEqual(['assignedLicenses']) + // everything unassigned lands in rest + expect(ids(slots.rest)).toEqual([ + 'accountEnabled', + 'displayName', + 'businessPhones', + 'proxyAddresses', + 'licenseAssignmentStates', + ]) + }) + + it('ignores override ids that are not visible and empty override arrays fall through to rest', () => { + const slots = getMobileCardSlots(USERS_COLUMNS, { primary: 'notAColumn', chips: [], details: [] }) + expect(slots.primary.id).toBe('displayName') // heuristic fallback + expect(slots.chips).toEqual([]) + expect(slots.details).toEqual([]) + expect(slots.restCount).toBe(7) + }) + + it('secondary never duplicates primary', () => { + const slots = getMobileCardSlots([col('RowKey'), col('Timestamp')]) + // RowKey matches both NAME_FIELDS and IDENTIFIER_FIELDS — must not appear twice + expect(slots.primary.id).toBe('RowKey') + expect(slots.secondary).toBeNull() + expect(ids(slots.details)).toEqual(['Timestamp']) + }) +}) + +describe('isStatusLike', () => { + it('detects boolean sortingFn (the get-cipp-filter-variant signal)', () => { + expect(isStatusLike(bool('anything'))).toBe(true) + }) + it('detects known status ids case-insensitively', () => { + expect(isStatusLike(col('complianceState'))).toBe(true) + expect(isStatusLike(col('Severity'))).toBe(true) + // the identity/device/custom test tables — Risk fell to the detail rows while Result sat + // in the chips row, two chips organised by two different systems on one card + expect(isStatusLike(col('Risk'))).toBe(true) + expect(isStatusLike(col('Result'))).toBe(true) + }) + it('detects small select filters, rejects large ones', () => { + expect(isStatusLike(col('x', { filterVariant: 'select', filterSelectOptions: ['a', 'b'] }))).toBe(true) + expect( + isStatusLike(col('x', { filterVariant: 'select', filterSelectOptions: ['a', 'b', 'c', 'd', 'e', 'f', 'g'] })) + ).toBe(false) + }) + it('rejects plain text columns', () => { + expect(isStatusLike(col('displayName'))).toBe(false) + }) +}) diff --git a/tests/components/CippTable/util-subTables.test.js b/tests/components/CippTable/util-subTables.test.js new file mode 100644 index 000000000000..bb3ee02a32a2 --- /dev/null +++ b/tests/components/CippTable/util-subTables.test.js @@ -0,0 +1,62 @@ +import { + dataHasPopulatedColumn, + resolveSubTableSimpleColumns, + subTableIsSelected, + subTableShowsCachedColumn, + getSubTableDisplayColumnIds, + columnOrderHasStaleIds, +} from '../../../src/components/CippTable/util-subTables' + +const membersSub = { + id: 'members', + header: 'Members', + cachedColumn: 'membersCsv', +} + +describe('util-subTables', () => { + it('selects a subTable only when its id is in simpleColumns', () => { + expect(subTableIsSelected(membersSub, ['displayName', 'members'])).toBe(true) + expect(subTableIsSelected(membersSub, ['displayName'])).toBe(false) + expect(subTableIsSelected(membersSub, [])).toBe(true) + }) + + it('uses the cached column when that field is present on the data', () => { + const cached = [{ id: '1', membersCsv: 'Jane, Bob' }] + const live = [{ id: '1', displayName: 'Finance' }] + const liveWithEmptyCsv = [{ id: '1', displayName: 'Finance', membersCsv: '' }] + + expect(dataHasPopulatedColumn(cached, 'membersCsv')).toBe(true) + expect(dataHasPopulatedColumn(liveWithEmptyCsv, 'membersCsv')).toBe(false) + expect(subTableShowsCachedColumn(membersSub, cached)).toBe(true) + expect(subTableShowsCachedColumn(membersSub, live)).toBe(false) + expect(subTableShowsCachedColumn(membersSub, liveWithEmptyCsv)).toBe(false) + expect( + resolveSubTableSimpleColumns(['displayName', 'members'], [membersSub], cached) + ).toEqual(['displayName', 'membersCsv']) + expect( + resolveSubTableSimpleColumns(['displayName', 'members'], [membersSub], live) + ).toEqual(['displayName', 'members']) + }) + + it('maps subTables to the active display column ids', () => { + const cached = [{ id: '1', membersCsv: 'Jane, Bob' }] + const live = [{ id: '1', displayName: 'Finance' }] + + expect( + getSubTableDisplayColumnIds([membersSub], ['displayName', 'members'], cached) + ).toEqual(['membersCsv']) + expect( + getSubTableDisplayColumnIds([membersSub], ['displayName', 'members'], live) + ).toEqual(['members']) + }) + + it('detects stale column order ids that are not on the table', () => { + expect(columnOrderHasStaleIds(['displayName', 'members'], ['displayName', 'membersCsv'])).toBe( + true + ) + expect( + columnOrderHasStaleIds(['displayName', 'membersCsv'], ['displayName', 'membersCsv']) + ).toBe(false) + expect(columnOrderHasStaleIds(['mrt-row-select', 'displayName'], ['displayName'])).toBe(false) + }) +}) diff --git a/tests/components/CippTable/util-tablemode.test.jsx b/tests/components/CippTable/util-tablemode.test.jsx index 8497f9d0dde4..790704246c9d 100644 --- a/tests/components/CippTable/util-tablemode.test.jsx +++ b/tests/components/CippTable/util-tablemode.test.jsx @@ -42,6 +42,26 @@ describe('utilTableMode', () => { expect(result.muiPaginationProps.rowsPerPageOptions).toBeDefined() }) + it('narrow table slims the footer so it cannot wrap below MRT 720px pivot', () => { + const result = utilTableMode({}, false, null, [], false, null, '380px', defaultSettings, 'table', true) + expect(result.muiPaginationProps.showRowsPerPage).toBe(false) + expect(result.muiPaginationProps.showFirstButton).toBe(false) + expect(result.muiPaginationProps.showLastButton).toBe(false) + }) + + it('narrow table page-scrolls instead of keeping an inner scroll viewport', () => { + const result = utilTableMode({}, false, null, [], false, null, '380px', defaultSettings, 'table', true) + expect(result.muiTableContainerProps.sx.maxHeight).toBe('none') + }) + + it('wide table keeps the full footer and the viewport-budget maxHeight', () => { + const result = utilTableMode({}, false, null, [], false, null, '380px', defaultSettings, 'table', false) + expect(result.muiPaginationProps.showRowsPerPage).toBeUndefined() + expect(result.muiPaginationProps.showFirstButton).toBeUndefined() + expect(result.muiPaginationProps.showLastButton).toBeUndefined() + expect(result.muiTableContainerProps.sx.maxHeight).toBe('calc(100vh - 380px)') + }) + it('returns table container height config', () => { const result = utilTableMode({}, false, null, [], false, null, '500px', defaultSettings) expect(result.muiTableContainerProps).toBeDefined() diff --git a/tests/components/CippWizard/CippWizardAutopilotImport.stories.jsx b/tests/components/CippWizard/CippWizardAutopilotImport.stories.jsx new file mode 100644 index 000000000000..5ad28e86c866 --- /dev/null +++ b/tests/components/CippWizard/CippWizardAutopilotImport.stories.jsx @@ -0,0 +1,75 @@ +import React from 'react' +import { http, HttpResponse } from 'msw' +import { within, expect, userEvent } from 'storybook/test' +import { useForm } from 'react-hook-form' +import { CippWizardAutopilotImport } from '../../../src/components/CippWizard/CippWizardAutopilotImport' +import { shrinkToPhoneViewport } from '../../viewport' + +// The six the real autopilot wizard passes — the count is what made the row overflow. +const fields = [ + { friendlyName: 'Serialnumber', propertyName: 'SerialNumber' }, + { friendlyName: 'Manufacturer', propertyName: 'oemManufacturerName' }, + { friendlyName: 'Model', propertyName: 'modelName' }, + { friendlyName: 'Product ID', propertyName: 'productKey' }, + { friendlyName: 'Hardware hash', propertyName: 'hardwareHash' }, + { friendlyName: 'Group Tag', propertyName: 'groupTag' }, +] + +const handlers = [ + http.get('*/api/ListGraphRequest', () => HttpResponse.json({ Results: [] })), + http.get('*/api/ListGraphExplorerPresets', () => HttpResponse.json({ Results: [] })), +] + +const Harness = () => { + const formControl = useForm({ mode: 'onChange', defaultValues: { autopilotData: [] } }) + return ( + {}} + onPreviousStep={() => {}} + /> + ) +} + +export default { + title: 'Components/CippWizard/CippWizardAutopilotImport', + component: CippWizardAutopilotImport, + parameters: { msw: { handlers } }, +} + +// A 32px badge, six 150px fields and a 48px delete came to ~1010px in a row whose only +// concession was overflowX:auto — a nested sideways scroller inside a full-screen dialog. +// Whether it fits now is a claim only a real browser can settle. +export const PhoneWidth = { + render: () => , + play: async ({ canvasElement }) => { + const onAPhone = await shrinkToPhoneViewport() + const body = within(document.body) + + // At phone width the table is a card list, so the import buttons are behind the FAB + // rather than in a card header — the same route a user takes. + if (onAPhone) { + await userEvent.click(await body.findByRole('button', { name: 'Page actions' })) + } + await userEvent.click(await body.findByRole('button', { name: /manual import/i })) + const dialog = await body.findByRole('dialog') + if (!onAPhone) return + + const rows = dialog.querySelectorAll('[data-testid="manual-row"]') + expect(rows.length).toBeGreaterThan(0) + rows.forEach((row) => { + expect(row.scrollWidth).toBeLessThanOrEqual(row.clientWidth) + }) + + // and the fields are stacked, not side by side + const inputs = rows[0].querySelectorAll('input') + expect(inputs.length).toBe(fields.length) + expect(inputs[1].getBoundingClientRect().top).toBeGreaterThan( + inputs[0].getBoundingClientRect().bottom + ) + }, +} diff --git a/tests/components/CippWizard/CippWizardAutopilotImport.test.jsx b/tests/components/CippWizard/CippWizardAutopilotImport.test.jsx new file mode 100644 index 000000000000..3720e883f933 --- /dev/null +++ b/tests/components/CippWizard/CippWizardAutopilotImport.test.jsx @@ -0,0 +1,85 @@ +import React from 'react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useForm } from 'react-hook-form' +import { renderWithProviders } from '../../test-utils' +import { CippWizardAutopilotImport } from '../../../src/components/CippWizard/CippWizardAutopilotImport' + +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ isMobile: false })) +// partial mock: real module spread first, so new exports keep working here +vi.mock('../../../src/hooks/use-breakpoint', async (importOriginal) => ({ + ...(await importOriginal()), + useIsMobileLayout: () => layoutState.isMobile, + useIsTabletLayout: () => false, + useTableViewMode: () => 'table', +})) + +vi.mock('../../../src/api/ApiCall', () => ({ + ApiGetCall: vi.fn(() => ({ data: undefined, isFetching: false, isSuccess: false })), + ApiPostCall: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), + ApiGetCallWithPagination: vi.fn(() => ({ data: undefined, isFetching: false })), +})) + +// The six the real autopilot wizard passes — the count is the point. +const fields = [ + { friendlyName: 'Serialnumber', propertyName: 'SerialNumber' }, + { friendlyName: 'Manufacturer', propertyName: 'oemManufacturerName' }, + { friendlyName: 'Model', propertyName: 'modelName' }, + { friendlyName: 'Product ID', propertyName: 'productKey' }, + { friendlyName: 'Hardware hash', propertyName: 'hardwareHash' }, + { friendlyName: 'Group Tag', propertyName: 'groupTag' }, +] + +const Harness = () => { + const formControl = useForm({ mode: 'onChange', defaultValues: { autopilotData: [] } }) + return ( + {}} + onPreviousStep={() => {}} + /> + ) +} + +const openManualImport = async () => { + const user = userEvent.setup() + renderWithProviders() + await user.click(await screen.findByRole('button', { name: /manual import/i })) + return within(await screen.findByRole('dialog')) +} + +beforeEach(() => { + layoutState.isMobile = false +}) + +describe('CippWizardAutopilotImport manual entry', () => { + // Six 150px fields plus an index badge and a delete button come to ~1010px, which on a + // phone was reachable only by scrolling a nested container inside a full-screen dialog. + it('gives each device its own card on a phone', async () => { + layoutState.isMobile = true + const dialog = await openManualImport() + + expect(dialog.getByText('Device 1')).toBeInTheDocument() + expect(dialog.getByRole('button', { name: 'Remove device 1' })).toBeInTheDocument() + // every field still there, just stacked + fields.forEach((field) => { + expect(dialog.getByLabelText(field.friendlyName)).toBeInTheDocument() + }) + }) + + it('keeps the single scrolling row on desktop', async () => { + const dialog = await openManualImport() + + expect(dialog.queryByText('Device 1')).not.toBeInTheDocument() + expect(dialog.queryByRole('button', { name: 'Remove device 1' })).not.toBeInTheDocument() + fields.forEach((field) => { + expect(dialog.getByLabelText(field.friendlyName)).toBeInTheDocument() + }) + }) +}) diff --git a/tests/components/CippWizard/CippWizardPage.stories.jsx b/tests/components/CippWizard/CippWizardPage.stories.jsx new file mode 100644 index 000000000000..5fd81bb92a3b --- /dev/null +++ b/tests/components/CippWizard/CippWizardPage.stories.jsx @@ -0,0 +1,83 @@ +import React from 'react' +import { within, expect, userEvent, waitFor } from 'storybook/test' +import { Typography } from '@mui/material' +import CippWizardPage from '../../../src/components/CippWizard/CippWizardPage' +import { CippWizardStepButtons } from '../../../src/components/CippWizard/CippWizardStepButtons' +import { shrinkToPhoneViewport, growToDesktopViewport } from '../../viewport' + +// A step that renders nothing but the shared button row — the layout under test is the +// wizard shell, not any particular step's form. +const Step = (props) => ( + <> + Step content + + +) + +// Five steps with the real wizards' label lengths; vacation mode is exactly this shape. +const steps = [ + { title: 'tenant', description: 'Tenant Selection', component: Step }, + { title: 'user', description: 'User Selection', component: Step }, + { title: 'actions', description: 'Vacation Actions', component: Step }, + { title: 'schedule', description: 'Schedule', component: Step }, + { title: 'review', description: 'Review & Submit', component: Step }, +] + +export default { + title: 'Components/CippWizard/CippWizardPage', + component: CippWizardPage, + parameters: { msw: { handlers: [] } }, +} + +const args = { postUrl: '/api/AddVacationMode', wizardTitle: 'Vacation Mode', steps } + +// jsdom has no layout engine, so overflow and stacking order are invisible to the unit +// tests. This is the only place they can be measured. +export const PhoneWidth = { + render: () => , + play: async ({ canvasElement }) => { + const onAPhone = await shrinkToPhoneViewport() + const canvas = within(canvasElement) + await canvas.findByText('Step content') + if (!onAPhone) return + + // the stepper is replaced, not merely restyled. findBy, not getBy: useMediaQuery reacts to + // the resize on a later tick, and "Step content" is in both branches so it settles nothing + await canvas.findByText('Step 1 of 5') + expect(canvasElement.querySelector('.MuiStepper-root')).toBeNull() + + // nothing in the card reaches past the screen + const card = canvasElement.querySelector('.MuiCard-root') + expect(card.scrollWidth).toBeLessThanOrEqual(card.clientWidth) + + // advancing moves the bar + await userEvent.click(canvas.getByRole('button', { name: /next step/i })) + await waitFor(() => expect(canvas.getByText('Step 2 of 5')).toBeInTheDocument()) + + // column-reverse: the primary action sits above Back, and both span the card + const next = canvas.getByRole('button', { name: /next step/i }) + const back = canvas.getByRole('button', { name: /^back$/i }) + expect(back.getBoundingClientRect().top).toBeGreaterThan(next.getBoundingClientRect().top) + expect(next.getBoundingClientRect().width).toBeGreaterThan( + card.getBoundingClientRect().width * 0.7 + ) + }, +} + +// The other half of the contract: none of this reaches desktop. +export const DesktopWidth = { + render: () => , + play: async ({ canvasElement }) => { + // Claim the width rather than inherit it — PhoneWidth shares this page and shrinks it. + await growToDesktopViewport() + const canvas = within(canvasElement) + await canvas.findByText('Step content') + + await waitFor(() => expect(canvasElement.querySelector('.MuiStepper-root')).not.toBeNull()) + expect(canvas.queryByRole('progressbar')).toBeNull() + expect(canvas.queryByText('Step 1 of 5')).toBeNull() + + const card = canvasElement.querySelector('.MuiCard-root') + expect(card.scrollWidth).toBeLessThanOrEqual(card.clientWidth) + }, +} diff --git a/tests/components/CippWizard/CippWizardVacationActions.test.jsx b/tests/components/CippWizard/CippWizardVacationActions.test.jsx index 043731c434e0..522c8e8744d5 100644 --- a/tests/components/CippWizard/CippWizardVacationActions.test.jsx +++ b/tests/components/CippWizard/CippWizardVacationActions.test.jsx @@ -148,6 +148,28 @@ describe('CippWizardVacationActions', () => { ).not.toBeInTheDocument() expect(formApi.getValues('enableCAExclusion')).toBeFalsy() }) + + it('offers the location alert exclusion without Conditional Access', async () => { + // Tenants without CA policies still get location-based audit alerts, so this switch + // must stand on its own rather than hide inside the CA branch. + renderWithProviders() + + expect( + screen.getByText('Exclude from location-based audit log alerts') + ).toBeInTheDocument() + + await setField('excludeLocationAuditAlerts', true) + + await waitFor(() => + expect( + screen.getByText(/does not require a Conditional Access policy/i) + ).toBeInTheDocument() + ) + expect( + screen.queryByText(/uses group-based exclusions/i) + ).not.toBeInTheDocument() + expect(formApi.getValues('enableCAExclusion')).toBeFalsy() + }) }) // The out-of-office branch renders a rich-text editor that does not mount under jsdom diff --git a/tests/components/CippWizard/wizard-steps.test.jsx b/tests/components/CippWizard/wizard-steps.test.jsx new file mode 100644 index 000000000000..19db18ab9e5f --- /dev/null +++ b/tests/components/CippWizard/wizard-steps.test.jsx @@ -0,0 +1,100 @@ +import React from "react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import { renderWithProviders } from "../../test-utils"; +import { WizardSteps } from "../../../src/components/CippWizard/wizard-steps"; + +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ isMobile: false })); +vi.mock("../../../src/hooks/use-breakpoint", () => ({ + useIsMobileLayout: () => layoutState.isMobile, + useIsTabletLayout: () => false, + useTableViewMode: () => "table", +})); + +const steps = [ + { title: "tenant", description: "Tenant Selection" }, + { title: "user", description: "User Selection" }, + { title: "actions", description: "Vacation Actions" }, + { title: "schedule", description: "Schedule" }, + { title: "review", description: "Review & Submit" }, +]; + +beforeEach(() => { + layoutState.isMobile = false; +}); + +describe("WizardSteps", () => { + it("keeps the full stepper on desktop", () => { + renderWithProviders(); + + expect(screen.getByText("Tenant Selection")).toBeInTheDocument(); + expect(screen.getByText("Review & Submit")).toBeInTheDocument(); + expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); + }); + + it("collapses to a progress header on a phone", () => { + layoutState.isMobile = true; + renderWithProviders(); + + expect(screen.getByText("Step 3 of 5")).toBeInTheDocument(); + expect(screen.getByText("Vacation Actions")).toBeInTheDocument(); + // the other four steps are not competing for the same 326px + expect(screen.queryByText("Tenant Selection")).not.toBeInTheDocument(); + expect(screen.queryByText("Review & Submit")).not.toBeInTheDocument(); + + const bar = screen.getByRole("progressbar"); + expect(bar).toHaveAttribute("aria-valuenow", "60"); + }); + + // The vertical variant is not wizard navigation: GDAP onboarding feeds it server-side + // steps where each step's message and pass/fail state IS the content. + it("leaves the vertical status list alone on a phone", () => { + layoutState.isMobile = true; + const onboarding = [ + { title: "invite", description: "Invite accepted", error: false }, + { title: "roles", description: "Role assignment failed: insufficient privileges", error: true }, + ]; + renderWithProviders(); + + expect(screen.getByText("Invite accepted")).toBeInTheDocument(); + expect( + screen.getByText("Role assignment failed: insufficient privileges") + ).toBeInTheDocument(); + expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); + }); + + it("carries the current step's error and loading states into the bar", () => { + layoutState.isMobile = true; + const failing = [{ description: "Deploying" }, { description: "Failed", error: true }]; + const { unmount } = renderWithProviders( + + ); + expect(screen.getByRole("progressbar").className).toMatch(/colorError/); + unmount(); + + const running = [{ description: "Deploying", loading: true }]; + renderWithProviders(); + expect(screen.getByRole("progressbar").className).toMatch(/indeterminate/); + }); + + it("survives an activeStep past the end of the visible steps", () => { + layoutState.isMobile = true; + // handleNext counts against the unfiltered step list, so this really happens on wizards + // whose steps are conditionally hidden. + renderWithProviders( + + ); + + expect(screen.getByText("Step 3 of 3")).toBeInTheDocument(); + expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "100"); + }); + + it("renders nothing broken for an empty step list", () => { + layoutState.isMobile = true; + renderWithProviders(); + + expect(screen.getByText("No steps")).toBeInTheDocument(); + expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "0"); + }); +}); diff --git a/tests/components/ExecutiveReportButton.test.jsx b/tests/components/ExecutiveReportButton.test.jsx index 0c522fc1d59d..cf86374b85bc 100644 --- a/tests/components/ExecutiveReportButton.test.jsx +++ b/tests/components/ExecutiveReportButton.test.jsx @@ -1,5 +1,5 @@ import React from 'react' -import { screen } from '@testing-library/react' +import { screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { renderWithProviders } from '../test-utils' import { ExecutiveReportButton } from '../../src/components/ExecutiveReportButton' @@ -99,4 +99,50 @@ describe('ExecutiveReportButton', () => { expect(onClick).toHaveBeenCalled() expect(await screen.findByRole('dialog')).toBeInTheDocument() }) + + // The 320px config rail would leave the preview about 70px wide on a phone, so below md it + // moves into a drawer. Both homes render the same panel, and the toggles have to keep + // working from the drawer. + describe('section configuration on a phone', () => { + const openSections = async () => { + renderWithProviders() + await userEvent.click(screen.getByRole('button', { name: /executive summary/i })) + await screen.findByRole('dialog') + await userEvent.click(screen.getByRole('button', { name: 'Report sections' })) + // jsdom applies no media queries, so the desktop rail is in the document too — every + // query here has to be scoped to the drawer or it matches both copies. + return within(document.querySelector('.MuiDrawer-paper')) + } + + it('opens the sections panel in a drawer', async () => { + const drawer = await openSections() + + expect(drawer.getByText('Report Sections')).toBeVisible() + expect(drawer.getByText('Executive Summary')).toBeVisible() + expect(drawer.getByText('Shadow AI Report')).toBeVisible() + }) + + it('toggles a section from inside the drawer', async () => { + const drawer = await openSections() + + const deviceRow = drawer.getByText('Device Management').closest('.MuiPaper-root') + const toggle = within(deviceRow).getByRole('switch') + expect(toggle).toBeChecked() + + await userEvent.click(toggle) + + expect(toggle).not.toBeChecked() + // the footer count is the shared state both panels read + expect(screen.getByText(/Sections enabled: 6 of 9/)).toBeInTheDocument() + }) + + it('lifts the drawer above the dialog that opened it', async () => { + await openSections() + + // A stock Drawer sits below a Dialog and would open behind the preview. + const drawer = document.querySelector('.MuiDrawer-root') + expect(drawer).not.toBeNull() + expect(window.getComputedStyle(drawer).zIndex).toBe('1301') + }) + }) }) diff --git a/tests/components/PrivateRoute.test.jsx b/tests/components/PrivateRoute.test.jsx index 14aa32ef9caa..a8f8f4dc51a8 100644 --- a/tests/components/PrivateRoute.test.jsx +++ b/tests/components/PrivateRoute.test.jsx @@ -84,6 +84,26 @@ describe('PrivateRoute', () => { expect(screen.queryByText('app content')).not.toBeInTheDocument() }) + it('shows the server explanation when a signed-in identity is denied (e.g. IP blocked)', async () => { + // real SWA session, but CIPP refused the caller and said why - the wording must + // surface instead of the misleading "session expired" prompt + authState.swa = result({ data: swaPrincipal() }) + authState.me = result({ + data: { + clientPrincipal: null, + permissions: [], + message: 'Your IP address (203.0.113.7) is not in the allowed range for your role(s)', + }, + }) + renderRoute() + + await waitFor(() => { + expect(screen.getByText(/not in the allowed range/)).toBeInTheDocument() + }) + expect(screen.getByText('Access Denied')).toBeInTheDocument() + expect(screen.queryByText('Sign in to CIPP')).not.toBeInTheDocument() + }) + it('shows the sign-in page when the session has no identity in either shape', async () => { // settled /.auth/me with neither clientPrincipal nor easyauth array authState.swa = result({ data: {} }) diff --git a/tests/components/ReleaseNotesDialog.test.jsx b/tests/components/ReleaseNotesDialog.test.jsx index e74a1c767f51..2468b0f51af8 100644 --- a/tests/components/ReleaseNotesDialog.test.jsx +++ b/tests/components/ReleaseNotesDialog.test.jsx @@ -1,5 +1,5 @@ import React from 'react' -import { screen, waitFor } from '@testing-library/react' +import { screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { renderWithProviders } from '../test-utils' @@ -8,6 +8,13 @@ import { renderWithProviders } from '../test-utils' const versionState = vi.hoisted(() => ({ version: '10.8.2' })) vi.mock('../../public/version.json', () => ({ default: versionState })) +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ isMobile: false })) +vi.mock('../../src/hooks/use-breakpoint', async (importOriginal) => ({ + ...(await importOriginal()), + useIsMobileLayout: () => layoutState.isMobile, +})) + vi.mock('../../src/api/ApiCall', async () => (await import('../mocks/api-call')).apiCallMock()) import { api, getResult } from '../mocks/api-call' @@ -48,6 +55,7 @@ const PERMANENT_HIDE_KEY = 'cipp_release_notice_permanently_hidden' const flushEffects = () => new Promise((resolve) => setTimeout(resolve, 0)) beforeEach(() => { + layoutState.isMobile = false versionState.version = '10.8.2' api.get = catalogResult window.localStorage.clear() @@ -55,18 +63,36 @@ beforeEach(() => { }) describe('ReleaseNotesDialog', () => { - it('opens on the .0 base release even when running a hotfix build', async () => { + // A hotfix release body is only the delta since the feature release, so opening on it tells + // the user almost nothing. Display the newest vX.Y.0 instead — dismissal still tracks the + // running tag, which is what the reopen-forever bug hinged on (see the next test). + it('opens on the newest .0 release, not on a hotfix', async () => { renderWithProviders() - expect(await screen.findByText('Release notes for v10.8.0 - Ramos Melon Fizz')).toBeInTheDocument() - expect(screen.getByText('Notes for the base release of the 10.8 series')).toBeInTheDocument() + expect( + await screen.findByDisplayValue('v10.9.0 - Something Newer') + ).toBeInTheDocument() + expect(screen.queryByText('Notes for the hotfix that is actually running')).toBeNull() + }) + + it('still lets you pick a hotfix release from the picker', async () => { + const user = userEvent.setup() + renderWithProviders() + await screen.findByDisplayValue('v10.9.0 - Something Newer') + + await user.click(screen.getByRole('combobox')) + await user.click(await screen.findByText('v10.8.2 - Hotfix')) + + expect( + await screen.findByText('Notes for the hotfix that is actually running') + ).toBeInTheDocument() }) it('stays dismissed on reload after "Don\'t show until next release"', async () => { const user = userEvent.setup() const { unmount } = renderWithProviders() - await screen.findByText('Release notes for v10.8.0 - Ramos Melon Fizz') + await screen.findByDisplayValue('v10.9.0 - Something Newer') await user.click(screen.getByRole('button', { name: "Don't show until next release" })) await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) @@ -87,7 +113,45 @@ describe('ReleaseNotesDialog', () => { renderWithProviders() - expect(await screen.findByText('Release notes for v10.9.0 - Something Newer')).toBeInTheDocument() + expect(await screen.findByDisplayValue('v10.9.0 - Something Newer')).toBeInTheDocument() + }) + + // On phones the two low-emphasis actions live behind the kebab as bottom-sheet rows — + // the same actions treatment as the rest of the mobile surface. + it('puts GitHub and permanent dismiss behind the kebab sheet on mobile', async () => { + layoutState.isMobile = true + const user = userEvent.setup() + renderWithProviders() + // the house pick-one pattern: a trigger, not a text input — no keyboard to summon + const trigger = await screen.findByRole('button', { name: /switch release/i }) + expect(trigger).toHaveTextContent('v10.9.0 - Something Newer') + expect(screen.queryByDisplayValue('v10.9.0 - Something Newer')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'More options' })) + // the desktop footer's copy is only display:none'd by a media query jsdom can't + // evaluate — scope to the sheet's drawer paper + const github = await screen.findByRole('link', { name: /view release notes on github/i }) + expect(github).toHaveAttribute('href', 'https://github.com/CyberDrain/CIPP/releases/tag/v10.9.0') + const sheet = within(github.closest('.MuiDrawer-paper')) + + await user.click(sheet.getByText("Don't show again")) + await flushEffects() + expect(window.localStorage.getItem(PERMANENT_HIDE_KEY)).toBe('true') + }) + + it('switches release from the mobile sheet', async () => { + layoutState.isMobile = true + const user = userEvent.setup() + renderWithProviders() + + await user.click(await screen.findByRole('button', { name: /switch release/i })) + const sheet = within((await screen.findByText('Release')).closest('.MuiDrawer-paper')) + await user.click(sheet.getByText('v10.8.2 - Hotfix')) + + expect(await screen.findByText('Notes for the hotfix that is actually running')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /switch release/i })).toHaveTextContent( + 'v10.8.2 - Hotfix' + ) }) it('falls back to the .0 notes when the running version has no release of its own', async () => { @@ -95,7 +159,7 @@ describe('ReleaseNotesDialog', () => { renderWithProviders() - expect(await screen.findByText('Release notes for v10.9.0 - Something Newer')).toBeInTheDocument() + expect(await screen.findByDisplayValue('v10.9.0 - Something Newer')).toBeInTheDocument() }) it('honours a permanent dismissal', async () => { diff --git a/tests/hooks/use-actions-dispatch.test.jsx b/tests/hooks/use-actions-dispatch.test.jsx new file mode 100644 index 000000000000..41226846eb64 --- /dev/null +++ b/tests/hooks/use-actions-dispatch.test.jsx @@ -0,0 +1,172 @@ +import React from "react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../test-utils"; +import { useActionsDispatch } from "../../src/hooks/use-actions-dispatch"; +import { CippApiDialog } from "../../src/components/CippComponents/CippApiDialog"; + +// Stable identities: a fresh object per call changes on every render and spins a loop. +// CippApiDialog calls reset() on open, so the post result needs the full shape. +const idlePost = vi.hoisted(() => ({ + mutate: vi.fn(), + reset: vi.fn(), + isPending: false, + isSuccess: false, + isError: false, + data: undefined, + error: null, +})); +const idleGet = vi.hoisted(() => ({ + data: undefined, + isFetching: false, + isLoading: false, + isSuccess: false, + isError: false, + refetch: vi.fn(), +})); +const idlePaginated = vi.hoisted(() => ({ + data: undefined, + isFetching: false, + isSuccess: false, + isError: false, + fetchNextPage: vi.fn(), + refetch: vi.fn(), +})); +const postOptions = vi.hoisted(() => []); +vi.mock("../../src/api/ApiCall", () => ({ + ApiPostCall: (options) => { + postOptions.push(options); + return idlePost; + }, + ApiGetCall: () => idleGet, + ApiGetCallWithPagination: () => idlePaginated, +})); + +// `dialog` is a fragment holding whichever surface the action needs, so reach past it. +const dialogPropsOf = (dialog) => + React.Children.toArray(dialog?.props?.children).find((child) => child?.type === CippApiDialog) + ?.props; + +const Harness = ({ actions, data = { id: "1" }, queryKeys, onDialogProps }) => { + const { visibleActions, dispatch, dialog } = useActionsDispatch({ actions, data, queryKeys }); + onDialogProps?.(dialogPropsOf(dialog)); + return ( + <> + {visibleActions.map((action) => ( + + ))} + {dialog} + + ); +}; + +beforeEach(() => { + idlePost.mutate.mockClear(); + postOptions.length = 0; +}); + +describe("useActionsDispatch", () => { + // The hook set ready:true before branching, which mounted CippApiDialog with + // api.noConfirm true; the dialog's mount effect then auto-submitted into the same + // customFunction the hook had just called directly. + it("runs a noConfirm customFunction exactly once per tap", async () => { + const user = userEvent.setup(); + const customFunction = vi.fn(); + renderWithProviders( + + ); + + await user.click(screen.getByRole("button", { name: "Refresh Data" })); + + await waitFor(() => expect(customFunction).toHaveBeenCalledTimes(1)); + // and it stays at one — the auto-submit effect must not fire on a later commit + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(customFunction).toHaveBeenCalledTimes(1); + }); + + // The dialog instance was reused and its auto-submit effect keys on + // [api.noConfirm, api.link], so a repeat of the same action left the deps unchanged and + // silently did nothing. + it("runs again when the same action is dispatched twice", async () => { + const user = userEvent.setup(); + const customFunction = vi.fn(); + renderWithProviders( + + ); + + await user.click(screen.getByRole("button", { name: "Refresh Data" })); + await waitFor(() => expect(customFunction).toHaveBeenCalledTimes(1)); + await user.click(screen.getByRole("button", { name: "Refresh Data" })); + + await waitFor(() => expect(customFunction).toHaveBeenCalledTimes(2)); + }); + + it("passes the caller's queryKeys through to the dialog", async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + + await user.click(screen.getByRole("button", { name: "Edit" })); + + // The dialog builds its mutation from relatedQueryKeys; without it the invalidation + // falls back to the hardcoded "Confirmation" title and the page never refreshes. + await waitFor(() => { + expect(postOptions.at(-1)?.relatedQueryKeys).toBe("Tenant History"); + }); + }); + + // The action was spread last, so any key it happened to carry silently beat the explicit + // prop — and every unknown key was forwarded onto the DOM by CippApiDialog. + it("does not let the action object override explicit dialog props", async () => { + const user = userEvent.setup(); + const props = vi.fn(); + renderWithProviders( + + ); + + await user.click(screen.getByRole("button", { name: "Edit" })); + + await waitFor(() => { + const last = props.mock.calls.at(-1)?.[0]; + expect(last?.row).toEqual({ id: "42" }); + }); + }); + + it("drops the dialog again once it closes", async () => { + const user = userEvent.setup(); + const props = vi.fn(); + renderWithProviders( + + ); + + await user.click(screen.getByRole("button", { name: "Edit" })); + await waitFor(() => expect(props.mock.calls.at(-1)?.[0]).toBeTruthy()); + + await user.keyboard("{Escape}"); + + // Left mounted, it holds a live mutation, an API subscription and a form instance for + // as long as the page lives — and on HeaderedTabbedLayout the page never unmounts. + await waitFor(() => expect(props.mock.calls.at(-1)?.[0]).toBeUndefined()); + }); + + it("hands a customComponent action to that component instead of a confirm dialog", async () => { + const user = userEvent.setup(); + const customComponent = vi.fn(() =>
    custom surface
    ); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Open" })); + + expect(await screen.findByTestId("custom")).toBeInTheDocument(); + }); +}); diff --git a/tests/hooks/use-breakpoint.test.jsx b/tests/hooks/use-breakpoint.test.jsx new file mode 100644 index 000000000000..ed8d92fbb183 --- /dev/null +++ b/tests/hooks/use-breakpoint.test.jsx @@ -0,0 +1,106 @@ +import React from 'react' +import { screen } from '@testing-library/react' +import { renderWithProviders, settingsWith } from '../test-utils' +import { useIsMobileLayout, useTableViewMode } from '../../src/hooks/use-breakpoint' + +// jsdom has no width-based matchMedia, so useIsMobileLayout is always false here — +// which is exactly why the explicit settings/prop path must exist and is what we test. +const Probe = (props) =>
    {useTableViewMode(props)}
    + +const renderMode = (props, settings) => + renderWithProviders(, settings ? { settings: settingsWith(settings) } : undefined) + +describe('useTableViewMode', () => { + it("defaults to 'table' on desktop-width (auto + not mobile)", () => { + renderMode() + expect(screen.getByTestId('mode')).toHaveTextContent('table') + }) + + it('settings.tableViewMode=cards forces cards', () => { + renderMode({}, { tableViewMode: 'cards' }) + expect(screen.getByTestId('mode')).toHaveTextContent('cards') + }) + + it('accepts {value,label} shaped settings', () => { + renderMode({}, { tableViewMode: { value: 'cards', label: 'Card list' } }) + expect(screen.getByTestId('mode')).toHaveTextContent('cards') + }) + + it('per-call viewMode prop beats settings', () => { + renderMode({ viewMode: 'table' }, { tableViewMode: 'cards' }) + expect(screen.getByTestId('mode')).toHaveTextContent('table') + }) + + it('simple always forces table, even against explicit cards', () => { + renderMode({ viewMode: 'cards', simple: true }, { tableViewMode: 'cards' }) + expect(screen.getByTestId('mode')).toHaveTextContent('table') + }) + + it('invalid mode values fall back to auto behavior', () => { + renderMode({}, { tableViewMode: 'bogus' }) + expect(screen.getByTestId('mode')).toHaveTextContent('table') + }) +}) + +// Width-aware stub so the two thresholds can be told apart. MUI asks in '@media (max-width:Npx)' +// form; anything it doesn't ask about is left unmatched. +const atWidth = (width) => { + const cache = new Map() + window.matchMedia = (query) => { + if (!cache.has(query)) { + const max = /max-width:\s*([\d.]+)px/.exec(query) + const min = /min-width:\s*([\d.]+)px/.exec(query) + cache.set(query, { + matches: (!max || width <= parseFloat(max[1])) && (!min || width >= parseFloat(min[1])), + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }) + } + return cache.get(query) + } +} + +afterEach(() => { + delete window.matchMedia +}) + +const SplitProbe = () => ( + <> +
    {String(useIsMobileLayout())}
    +
    {useTableViewMode()}
    + +) + +// The two thresholds are deliberately different. One query for both breaks an end either way: +// at md the 900-1200 band loses the side nav with no hamburger to open the drawer, at lg +// desktop-width tables become card lists. +describe('the chrome/table split', () => { + it('treats the 900-1200 band as mobile chrome but keeps tables tabular', () => { + atWidth(1000) + renderWithProviders() + + expect(screen.getByTestId('chrome')).toHaveTextContent('true') + expect(screen.getByTestId('mode')).toHaveTextContent('table') + }) + + it('moves both to mobile on a phone', () => { + atWidth(800) + renderWithProviders() + + expect(screen.getByTestId('chrome')).toHaveTextContent('true') + expect(screen.getByTestId('mode')).toHaveTextContent('cards') + }) + + it('leaves both on desktop above lg', () => { + atWidth(1300) + renderWithProviders() + + expect(screen.getByTestId('chrome')).toHaveTextContent('false') + expect(screen.getByTestId('mode')).toHaveTextContent('table') + }) +}) diff --git a/tests/hooks/use-history-dismiss.test.jsx b/tests/hooks/use-history-dismiss.test.jsx new file mode 100644 index 000000000000..b3bb32e8aa4d --- /dev/null +++ b/tests/hooks/use-history-dismiss.test.jsx @@ -0,0 +1,93 @@ +import React, { useState } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../test-utils"; +import { useHistoryDismiss } from "../../src/hooks/use-history-dismiss"; +import { resetOverlayHistory } from "../../src/utils/overlay-history"; + +const nextPop = () => + new Promise((resolve) => window.addEventListener("popstate", resolve, { once: true })); + +// The back gesture is history navigation; jsdom traverses asynchronously, and the resulting +// state update belongs inside act(). +const swipeBack = async () => { + await act(async () => { + const settled = nextPop(); + window.history.back(); + await settled; + }); +}; + +const Harness = ({ enabled = true, onClose }) => { + const [open, setOpen] = useState(false); + const close = () => { + setOpen(false); + onClose?.(); + }; + useHistoryDismiss(open, close, enabled); + return ( + <> + + {open && ( + <> +
    Row details
    + + + )} + + ); +}; + +afterEach(() => { + resetOverlayHistory(); +}); + +describe("useHistoryDismiss", () => { + it("dismisses the overlay on a back press instead of navigating the page", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Open details" })); + expect(screen.getByTestId("overlay")).toBeInTheDocument(); + + await swipeBack(); + + expect(screen.queryByTestId("overlay")).not.toBeInTheDocument(); + }); + + it("gives the entry back when the overlay closes on its own", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Open details" })); + await act(async () => { + const settled = nextPop(); + await user.click(screen.getByRole("button", { name: "Close details" })); + await settled; + }); + + // Closed once, by the button — and the history entry went with it, so the next back + // press is the page's again rather than a dead tap. + expect(onClose).toHaveBeenCalledTimes(1); + expect(window.history.state?.__cippOverlay).toBeUndefined(); + }); + + it("stays out of history when disabled", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Open details" })); + // Somewhere to go back to, so the gesture is a real navigation attempt. + window.history.pushState({}, ""); + await swipeBack(); + + // Desktop keeps today's behaviour: back belongs to the router, not the overlay. + expect(screen.getByTestId("overlay")).toBeInTheDocument(); + }); +}); diff --git a/tests/hooks/use-sheet-handoff.test.jsx b/tests/hooks/use-sheet-handoff.test.jsx new file mode 100644 index 000000000000..5d9276ef5987 --- /dev/null +++ b/tests/hooks/use-sheet-handoff.test.jsx @@ -0,0 +1,64 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ListItemButton, ListItemText } from "@mui/material"; +import { renderWithProviders } from "../test-utils"; +import { CippBottomSheet } from "../../src/components/CippComponents/CippBottomSheet"; +import { useSheetHandoff } from "../../src/hooks/use-sheet-handoff"; + +// A sheet row that closes the sheet and opens another Modal in the same tick leaves two +// Modals in flight; the outgoing Drawer restores scroll lock and aria-hidden on top of the +// overlay that just opened. The handoff waits for the exit before running the action. +const Harness = ({ onAction }) => { + const [open, setOpen] = React.useState(false); + const sheet = useSheetHandoff(() => setOpen(false)); + return ( + <> + + + sheet.run(onAction)}> + + + + + ); +}; + +describe("useSheetHandoff", () => { + it("runs the action only after the sheet has finished closing", async () => { + const user = userEvent.setup(); + const onAction = vi.fn(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Open sheet" })); + await user.click(await screen.findByText("Do the thing")); + + // the tap closes the sheet immediately, but the action is still parked + expect(onAction).not.toHaveBeenCalled(); + + await waitFor(() => expect(onAction).toHaveBeenCalledTimes(1)); + expect(screen.queryByText("Do the thing")).not.toBeInTheDocument(); + }); + + it("drops the parked action when the sheet is dismissed instead", async () => { + const user = userEvent.setup(); + const onAction = vi.fn(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Open sheet" })); + await screen.findByText("Do the thing"); + await user.keyboard("{Escape}"); + + await waitFor(() => expect(screen.queryByText("Do the thing")).not.toBeInTheDocument()); + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(onAction).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/layouts/AccountPopover.test.jsx b/tests/layouts/AccountPopover.test.jsx new file mode 100644 index 000000000000..ebf14be9b693 --- /dev/null +++ b/tests/layouts/AccountPopover.test.jsx @@ -0,0 +1,99 @@ +import React from "react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../test-utils"; +import { cippPrincipal } from "../mocks/fixtures"; + +vi.mock("next/navigation", () => ({ + usePathname: () => "/", + useRouter: () => ({ push: vi.fn() }), +})); + +// jsdom has no width-based matchMedia, so the nav pivot is driven by mocking the hook, and +// MUI's own useMediaQuery answers false there, i.e. the >= md side of the popover's mdDown +// gate. that pairing is the 900-1199 band: nav collapsed, still above md. +const layoutState = vi.hoisted(() => ({ isMobile: false })); +vi.mock("../../src/hooks/use-breakpoint", async (importOriginal) => ({ + ...(await importOriginal()), + useIsMobileLayout: () => layoutState.isMobile, +})); + +// stable identities, a fresh object per call re-renders forever +const idle = vi.hoisted(() => ({ + isSuccess: false, + isFetching: false, + isPending: false, + isError: false, + data: undefined, + mutate: () => {}, + reset: () => {}, + refetch: () => {}, +})); +const meResult = vi.hoisted(() => ({ + isSuccess: true, + isFetching: false, + isPending: false, + isError: false, + data: undefined, + refetch: () => {}, +})); +vi.mock("../../src/api/ApiCall", () => ({ + ApiGetCall: ({ url }) => (url === "/api/me" ? meResult : idle), + ApiPostCall: () => idle, + ApiGetCallWithPagination: () => ({ ...idle, fetchNextPage: () => {} }), +})); + +import { AccountPopover } from "../../src/layouts/account-popover"; + +const renderPopover = () => { + const onThemeSwitch = vi.fn(); + const onOpenSearch = vi.fn(); + renderWithProviders( + + ); + return { onThemeSwitch, onOpenSearch }; +}; + +// avatar fallback glyph for john@contoso.com, the popover's only trigger +const openPopover = async () => userEvent.click(await screen.findByText("J")); + +describe("AccountPopover", () => { + beforeEach(() => { + layoutState.isMobile = false; + meResult.data = cippPrincipal(["editor"]); + }); + + it("offers universal search and the theme toggle whenever the top bar hides their icons", async () => { + layoutState.isMobile = true; + const { onThemeSwitch, onOpenSearch } = renderPopover(); + + await openPopover(); + await userEvent.click(screen.getByText("Universal Search")); + expect(onOpenSearch).toHaveBeenCalled(); + + await openPopover(); + await userEvent.click(screen.getByText("Dark Mode")); + expect(onThemeSwitch).toHaveBeenCalled(); + }); + + it("leaves search and theme to the top bar while it still renders their icons", async () => { + renderPopover(); + + await openPopover(); + expect(screen.queryByText("Universal Search")).toBeNull(); + expect(screen.queryByText("Dark Mode")).toBeNull(); + }); + + it("does not repeat the signed-in identity that the trigger is already showing", async () => { + layoutState.isMobile = true; + renderPopover(); + + await openPopover(); + expect(screen.getAllByText("john@contoso.com")).toHaveLength(1); + }); +}); diff --git a/tests/layouts/HeaderedTabbedLayout.test.jsx b/tests/layouts/HeaderedTabbedLayout.test.jsx new file mode 100644 index 000000000000..c3cfeb1b7346 --- /dev/null +++ b/tests/layouts/HeaderedTabbedLayout.test.jsx @@ -0,0 +1,127 @@ +import React from "react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../test-utils"; + +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ mdDown: true })); +vi.mock("../../src/hooks/use-breakpoint", () => ({ + useIsMobileLayout: () => layoutState.mdDown, + useIsTabletLayout: () => false, + useTableViewMode: () => "table", +})); + +vi.mock("next/router", () => ({ + useRouter: () => ({ query: {}, push: vi.fn(), pathname: "/tenant/manage/edit" }), +})); +vi.mock("next/navigation", () => ({ usePathname: () => "/tenant/manage/edit" })); + +const idle = vi.hoisted(() => ({ + isSuccess: false, + isFetching: false, + isPending: false, + isError: false, + data: undefined, + mutate: () => {}, + reset: () => {}, + refetch: () => {}, +})); +vi.mock("../../src/api/ApiCall", () => ({ + ApiGetCall: () => idle, + ApiPostCall: () => idle, + ApiGetCallWithPagination: () => ({ ...idle, fetchNextPage: () => {} }), +})); + +import { HeaderedTabbedLayout } from "../../src/layouts/HeaderedTabbedLayout"; + +const tabOptions = [ + { label: "Edit Tenant", path: "/tenant/manage/edit", icon: "Settings" }, + { label: "Manage Drift", path: "/tenant/manage/drift", icon: "Sync" }, +]; + +const actions = [ + { + label: "Reset Password", + type: "POST", + url: "/api/ExecResetPass", + confirmText: "Reset the password?", + }, +]; + +const renderLayout = (props = {}) => + renderWithProviders( + +
    page content
    +
    + ); + +describe("HeaderedTabbedLayout mobile header", () => { + beforeEach(() => { + layoutState.mdDown = true; + }); + + it("keeps the header Actions menu on desktop and drops it on mobile", async () => { + renderLayout(); + expect(screen.queryByRole("button", { name: "Actions" })).not.toBeInTheDocument(); + + layoutState.mdDown = false; + renderLayout(); + await waitFor(() => + expect(screen.getAllByRole("button", { name: "Actions" }).length).toBeGreaterThan(0) + ); + }); + + // The title row's right half is empty below md — that is the slot the picker takes, so + // navigation costs no vertical space and does not depend on a FAB being on screen. + it("puts the tab picker in the title row on mobile, and tabs on desktop", async () => { + renderLayout(); + const picker = screen.getByRole("button", { name: /switch view/i }); + expect(picker).toHaveAccessibleName("Edit Tenant switch view"); + expect(screen.queryByRole("tab")).not.toBeInTheDocument(); + + const user = userEvent.setup(); + await user.click(picker); + const sheet = within((await screen.findByText("Views")).closest(".MuiDrawer-paper")); + expect(sheet.getByText("Manage Drift")).toBeInTheDocument(); + + layoutState.mdDown = false; + renderLayout(); + await waitFor(() => + expect(screen.getByRole("tab", { name: /Manage Drift/ })).toBeInTheDocument() + ); + }); + + // A FAB is for actions. With none to carry there is nothing to put in the corner. + it("renders no FAB when the page has no actions", () => { + renderLayout({ actions: [] }); + expect(screen.queryByRole("button", { name: /Page actions/ })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /switch view/i })).toBeInTheDocument(); + }); + + // The sheet closing and the overlay opening happen in one tick; MUI's modal manager has + // to settle the unmounting Drawer before the new one registers, or the overlay never + // becomes interactive. + it("opens the action's overlay from the sheet and leaves it open", async () => { + const user = userEvent.setup(); + renderLayout(); + + await user.click(screen.getByRole("button", { name: "Page actions" })); + await user.click(await screen.findByText("Reset Password")); + + // sheet goes away — keepMounted keeps its rows in the DOM, so closed means hidden + await waitFor(() => expect(screen.getByText("Reset Password")).not.toBeVisible()); + + // and the confirmation overlay is present and stays present + const confirm = await screen.findByText(/Reset the password\?/i, {}, { timeout: 3000 }); + expect(confirm).toBeInTheDocument(); + await new Promise((resolve) => setTimeout(resolve, 400)); + expect(screen.getByText(/Reset the password\?/i)).toBeInTheDocument(); + }); +}); diff --git a/tests/layouts/MobileNav.stories.jsx b/tests/layouts/MobileNav.stories.jsx new file mode 100644 index 000000000000..1c4977bbc1ea --- /dev/null +++ b/tests/layouts/MobileNav.stories.jsx @@ -0,0 +1,189 @@ +import React, { useState } from 'react' +import { within, expect, userEvent, waitFor } from 'storybook/test' +import { Box, Button } from '@mui/material' +import { MobileNav } from '../../src/layouts/mobile-nav' +import { shrinkToPhoneViewport } from '../viewport' + +const items = [ + { title: 'Dashboard', path: '/' }, + { + title: 'Identity Management', + path: '/identity', + items: [ + { title: 'Users', path: '/identity/administration/users' }, + { title: 'Groups', path: '/identity/administration/groups' }, + { title: 'Devices', path: '/identity/administration/devices' }, + ], + }, + { + title: 'Tenant Administration', + path: '/tenant', + items: [ + { title: 'Tenants', path: '/tenant/administration/tenants' }, + { title: 'Alerts', path: '/tenant/administration/alert-configuration' }, + ], + }, + { title: 'Tools', path: '/tools' }, + { title: 'Settings', path: '/cipp/settings' }, +] + +// Mirrors the open/close state Layout owns (layouts/index.js useMobileNav), so the drawer +// behaves here exactly as it does in the app. +const Harness = (props) => { + const [open, setOpen] = useState(false) + return ( + + + setOpen(true)} + onClose={() => setOpen(false)} + {...props} + /> + + ) +} + +export default { + title: 'Layouts/MobileNav', + component: MobileNav, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + }, +} + +export const Default = { + render: () => , +} + +// Only a real browser can settle this: jsdom runs no transitions, so the frame the close +// animation starts from does not exist there. +export const DragClosesFromWhereItWasLeft = { + render: () => , + play: async ({ canvasElement }) => { + const onAPhone = await shrinkToPhoneViewport() + const canvas = within(canvasElement) + + await userEvent.click(canvas.getByTestId('open-nav')) + const paper = await waitFor(() => { + const node = document.querySelector('.MuiDrawer-paper') + expect(node).not.toBeNull() + return node + }) + if (!onAPhone) { + return + } + await waitFor(() => + expect(new DOMMatrixReadOnly(getComputedStyle(paper).transform).m41).toBe(0) + ) + + // Dispatched on a node inside the paper and left to bubble: MUI reads event.target to + // decide the gesture started in the drawer, so firing at the document bails immediately. + const target = paper.querySelector('nav') ?? paper + const at = (clientX) => + new Touch({ identifier: 1, target, clientX, clientY: 400, pageX: clientX, pageY: 400 }) + const fire = (type, clientX) => + target.dispatchEvent( + new TouchEvent(type, { + bubbles: true, + cancelable: true, + touches: type === 'touchend' ? [] : [at(clientX)], + changedTouches: [at(clientX)], + }) + ) + + // MUI flags "maybe swiping" in React state on touchstart and ignores moves until that has + // been applied, so the gesture has to be spread across ticks like a real one. + const tick = () => new Promise((resolve) => setTimeout(resolve, 30)) + fire('touchstart', 300) + await tick() + for (const x of [285, 230, 160, 80, 40]) { + fire('touchmove', x) + await tick() + } + + const draggedTo = new DOMMatrixReadOnly(getComputedStyle(paper).transform).m41 + expect(draggedTo).toBeLessThan(-100) + fire('touchend', 40) + + // The exit has to continue from where the finger let go. Slide probes the paper's + // untranslated position when the exit starts (Slide.js getTranslateValue), and the browser + // takes that probe as the transition's start, which snaps the drawer wide open first. + const firstExitFrame = await new Promise((resolve) => { + requestAnimationFrame(() => + requestAnimationFrame(() => + resolve(new DOMMatrixReadOnly(getComputedStyle(paper).transform).m41) + ) + ) + }) + expect(firstExitFrame).toBeLessThan(draggedTo * 0.6) + + await waitFor(() => + expect(document.querySelector('.MuiDrawer-root').getAttribute('aria-hidden')).toBe('true') + ) + }, +} + +// enough rows to overflow a phone-height drawer once the group is expanded +const tallItems = [ + { title: 'Dashboard', path: '/' }, + { + title: 'CIPP', + path: '/cipp', + items: [ + { title: 'Custom Data', path: '/cipp/custom-data' }, + { + title: 'Advanced', + path: '/cipp/advanced', + items: [ + { title: 'Super Admin', path: '/cipp/advanced/super-admin/tenant-mode' }, + { title: 'Container Management', path: '/cipp/advanced/container-management/status' }, + { title: 'Authentication', path: '/cipp/advanced/authentication' }, + { title: 'Timers', path: '/cipp/advanced/timers' }, + ], + }, + { title: 'Settings', path: '/cipp/settings' }, + { title: 'Preferences', path: '/cipp/preferences' }, + ], + }, + ...Array.from({ length: 14 }, (_, index) => ({ + title: `Section ${index + 1}`, + path: `/section-${index + 1}`, + })), +] + +export const NavListIsTheOnlyScroller = { + render: () => , + play: async ({ canvasElement }) => { + const onAPhone = await shrinkToPhoneViewport() + const canvas = within(canvasElement) + + await userEvent.click(canvas.getByTestId('open-nav')) + const paper = await waitFor(() => { + const node = document.querySelector('.MuiDrawer-paper') + expect(node).not.toBeNull() + return node + }) + if (!onAPhone) { + return + } + await waitFor(() => + expect(new DOMMatrixReadOnly(getComputedStyle(paper).transform).m41).toBe(0) + ) + + await userEvent.click(await within(paper).findByText('CIPP')) + + // the list has to overflow, or the paper assertion below would pass for the wrong reason + const scroller = paper.querySelector('.simplebar-content-wrapper') + await waitFor(() => + expect(scroller.scrollHeight).toBeGreaterThan(scroller.clientHeight + 200) + ) + + // a scrollable paper carries the pinned sponsor up with it and leaves blank drawer below + expect(paper.scrollHeight).toBeLessThanOrEqual(paper.clientHeight + 1) + }, +} diff --git a/tests/layouts/MobileNav.test.jsx b/tests/layouts/MobileNav.test.jsx new file mode 100644 index 000000000000..6dc18ba06808 --- /dev/null +++ b/tests/layouts/MobileNav.test.jsx @@ -0,0 +1,77 @@ +import React from 'react' +import { describe, it, expect, vi } from 'vitest' +import { act } from '@testing-library/react' +import { renderWithProviders, settingsWith } from '../test-utils' + +vi.mock('next/navigation', () => ({ + usePathname: () => '/', + useRouter: () => ({ push: vi.fn() }), + useSearchParams: () => new URLSearchParams(''), +})) + +const idle = vi.hoisted(() => ({ + isSuccess: false, + isFetching: false, + isPending: false, + isError: false, + data: undefined, + mutate: () => {}, + reset: () => {}, + refetch: () => {}, +})) +vi.mock('../../src/api/ApiCall', () => ({ + ApiGetCall: () => idle, + ApiPostCall: () => idle, + ApiGetCallWithPagination: () => ({ ...idle, fetchNextPage: () => {} }), +})) + +import { MobileNav } from '../../src/layouts/mobile-nav' + +const items = [{ title: 'Dashboard', path: '/' }] + +// MUI binds touchstart/touchmove/touchend on the document, so the swipe lifecycle is driven +// with native events; userEvent emits pointer/mouse, which SwipeableDrawer ignores. +const touch = (el, type, x = 5, y = 200) => { + const event = new Event(type, { bubbles: true, cancelable: true }) + const point = { pageX: x, pageY: y, clientX: x, clientY: y } + Object.defineProperty(event, 'touches', { value: type === 'touchend' ? [] : [point] }) + Object.defineProperty(event, 'changedTouches', { value: [point] }) + act(() => { + el.dispatchEvent(event) + }) +} + +const renderNav = (props = {}) => { + const onOpen = vi.fn() + const onClose = vi.fn() + renderWithProviders( + , + { settings: settingsWith({ bookmarkSidebar: false }) } + ) + return { onOpen, onClose } +} + +describe('MobileNav', () => { + it('renders no edge swipe area', () => { + renderNav() + expect(document.querySelector('.PrivateSwipeArea-root')).toBeNull() + }) + + // MUI forces the modal open while a swipe is in progress (maybeSwiping), and a touch with no + // movement never sets isSwiping, so handleBodyTouchEnd bails before onOpen/onClose. The drawer + // animates in and straight back out with the app's open state untouched. + it('leaves the drawer closed on a left-edge tap', () => { + const { onOpen, onClose } = renderNav() + const target = document.querySelector('.PrivateSwipeArea-root') ?? document.body + const drawer = document.querySelector('.MuiDrawer-root') + expect(drawer.getAttribute('aria-hidden')).toBe('true') + + touch(target, 'touchstart') + expect(drawer.getAttribute('aria-hidden')).toBe('true') + + touch(target, 'touchend') + expect(drawer.getAttribute('aria-hidden')).toBe('true') + expect(onOpen).not.toHaveBeenCalled() + expect(onClose).not.toHaveBeenCalled() + }) +}) diff --git a/tests/layouts/TabbedLayout.test.jsx b/tests/layouts/TabbedLayout.test.jsx new file mode 100644 index 000000000000..f913a511e505 --- /dev/null +++ b/tests/layouts/TabbedLayout.test.jsx @@ -0,0 +1,261 @@ +import React from "react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Button } from "@mui/material"; +import { renderWithProviders } from "../test-utils"; + +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ isMobile: false, viewMode: "table" })); +// partial mock: real module spread first, so new exports keep working here +vi.mock("../../src/hooks/use-breakpoint", async (importOriginal) => ({ + ...(await importOriginal()), + useIsMobileLayout: () => layoutState.isMobile, + useIsTabletLayout: () => false, + useTableViewMode: () => layoutState.viewMode, +})); + +const routerState = vi.hoisted(() => ({ push: vi.fn(), pathname: "/dashboardv2" })); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: routerState.push }), + usePathname: () => routerState.pathname, + useSearchParams: () => new URLSearchParams(""), +})); + +// Stable identities: a fresh object per call re-renders forever (tests/mocks/api-call.js) +const idle = vi.hoisted(() => ({ + isSuccess: false, + isFetching: false, + isPending: false, + isError: false, + data: undefined, + mutate: () => {}, + reset: () => {}, + refetch: () => {}, +})); +vi.mock("../../src/api/ApiCall", () => ({ + ApiGetCall: () => idle, + ApiPostCall: () => idle, + ApiGetCallWithPagination: () => ({ ...idle, fetchNextPage: () => {} }), +})); + +import { TabbedLayout } from "../../src/layouts/TabbedLayout"; +import { CippPageActionsFab } from "../../src/components/CippComponents/CippPageActionsFab"; +import { CippDataTable } from "../../src/components/CippTable/CippDataTable"; + +const tabOptions = [ + { label: "Overview", path: "/dashboardv2", icon: "Dashboard" }, + { label: "Identity", path: "/dashboardv2/identity", icon: "Person" }, + { label: "Devices", path: "/dashboardv2/devices", icon: "Devices" }, +]; + +const picker = () => screen.getByRole("button", { name: /switch view/i }); +const queryPickers = () => screen.queryAllByRole("button", { name: /switch view/i }); + +// The trigger names the current view and so does its row in the sheet — scope sheet +// assertions to the sheet, or every current-tab query matches twice. +const openPicker = async (user) => { + await user.click(picker()); + const sheet = await screen.findByText("Views"); + return within(sheet.closest(".MuiDrawer-paper")); +}; + +describe("TabbedLayout", () => { + beforeEach(() => { + layoutState.isMobile = false; + layoutState.viewMode = "table"; + routerState.push = vi.fn(); + routerState.pathname = "/dashboardv2"; + }); + + it("renders a tab bar on desktop and no picker", () => { + renderWithProviders( + +
    page content
    +
    + ); + + expect(screen.getByRole("tab", { name: /Overview/ })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /Devices/ })).toBeInTheDocument(); + expect(queryPickers()).toHaveLength(0); + }); + + it("replaces the tab bar with a picker in the content flow on mobile", async () => { + layoutState.isMobile = true; + const user = userEvent.setup(); + renderWithProviders( + +
    page content
    +
    + ); + + expect(screen.queryByRole("tab")).not.toBeInTheDocument(); + // the trigger names where you are; the sheet is where the rest live + expect(picker()).toHaveAccessibleName("Overview switch view"); + + const sheet = await openPicker(user); + tabOptions.forEach((tab) => expect(sheet.getByText(tab.label)).toBeInTheDocument()); + }); + + // pages/index.js re-exports the dashboard, so it renders at "/" while every tab path is + // /dashboardv2/... — no match meant the trigger fell back to "Views" and the sheet had no + // check. An aliased route belongs to the tab whose page it re-exports: the first one. + it("treats an aliased route as the first tab instead of showing no selection", async () => { + layoutState.isMobile = true; + routerState.pathname = "/"; + const user = userEvent.setup(); + renderWithProviders( + +
    page content
    +
    + ); + + expect(picker()).toHaveAccessibleName("Overview switch view"); + + const sheet = await openPicker(user); + expect(sheet.getByText("Overview").closest('[role="button"]')).toHaveClass("Mui-selected"); + + // and tapping the aliased tab is still a no-op, not a navigation loop + await user.click(sheet.getByText("Overview")); + expect(routerState.push).not.toHaveBeenCalled(); + }); + + it("navigates when a tab row is tapped, and does nothing for the current tab", async () => { + layoutState.isMobile = true; + const user = userEvent.setup(); + renderWithProviders( + +
    page content
    +
    + ); + + let sheet = await openPicker(user); + await user.click(sheet.getByText("Devices")); + expect(routerState.push).toHaveBeenCalledWith("/dashboardv2/devices"); + + routerState.push = vi.fn(); + sheet = await openPicker(user); + await user.click(sheet.getByText("Overview")); + expect(routerState.push).not.toHaveBeenCalled(); + }); + + // A single destination is not navigation — View Group and View Device have one tab each and + // used to get a FAB whose sheet offered the page you were already on. + it("renders no picker when there is only one destination", () => { + layoutState.isMobile = true; + renderWithProviders( + +
    page content
    +
    + ); + + expect(queryPickers()).toHaveLength(0); + }); + + it("counts visible tabs, not configured ones, when deciding to render", async () => { + layoutState.isMobile = true; + const user = userEvent.setup(); + const gated = [tabOptions[0], { label: "Diagnostics", path: "/x", advanced: true }]; + + // one real tab plus one the user's advanced setting hides — nothing to switch between + const { unmount } = renderWithProviders( + +
    page content
    +
    + ); + expect(queryPickers()).toHaveLength(0); + unmount(); + + renderWithProviders( + +
    page content
    +
    + ); + const sheet = await openPicker(user); + expect(sheet.getByText("Overview")).toBeInTheDocument(); + expect(sheet.queryByText("Diagnostics")).not.toBeInTheDocument(); + }); + + // One control, one place, on every tabbed page — never annexing a heading that happens to + // be nearby on some page types and not others. + it("draws exactly one picker, in its own row, whatever the page renders", async () => { + layoutState.isMobile = true; + layoutState.viewMode = "cards"; + renderWithProviders( + + + + ); + + await waitFor(() => expect(screen.getByText("Relationships")).toBeInTheDocument()); + expect(queryPickers()).toHaveLength(1); + // the page's own heading is still a heading, not a control + expect(picker()).not.toHaveTextContent("Relationships"); + }); + + // Destinations used to ride in this sheet. A FAB is for a screen's primary action. + it("no longer puts destinations in the page FAB", async () => { + layoutState.isMobile = true; + const user = userEvent.setup(); + renderWithProviders( + + + + + + ); + + // the layout adds no FAB of its own any more — this one is the page's, and navigation + // sits in the content flow beside it + const fabs = screen.getAllByRole("button", { name: /Page actions/ }); + expect(fabs).toHaveLength(1); + expect(picker()).toBeInTheDocument(); + + // the sheet is a modal, so it aria-hides the page behind it — assert on its contents only + await user.click(fabs[0]); + expect(await screen.findByRole("button", { name: "Add Variable" })).toBeInTheDocument(); + expect(screen.queryByText("Identity")).not.toBeInTheDocument(); + expect(screen.queryByText("Devices")).not.toBeInTheDocument(); + expect(screen.queryByText("Views")).not.toBeInTheDocument(); + }); + + // The defect the FAB placement caused: the card list claimed the corner during select mode + // but drew no FAB there, and the layout stood down because the corner was claimed — leaving + // no way at all to reach the other views until selection ended. + it("keeps navigation reachable while a card list is in select mode", async () => { + layoutState.isMobile = true; + layoutState.viewMode = "cards"; + const user = userEvent.setup(); + renderWithProviders( + + + + ); + + await waitFor(() => expect(queryPickers()).toHaveLength(1)); + + await user.click(screen.getByRole("button", { name: /^Select$/ })); + await waitFor(() => + expect(screen.getByRole("button", { name: /^Cancel$/ })).toBeInTheDocument() + ); + + // this is the assertion the FAB placement could not satisfy + expect(queryPickers()).toHaveLength(1); + const sheet = await openPicker(user); + expect(sheet.getByText("Devices")).toBeInTheDocument(); + }); +}); diff --git a/tests/layouts/header-overflow.stories.jsx b/tests/layouts/header-overflow.stories.jsx new file mode 100644 index 000000000000..3ee8d203204b --- /dev/null +++ b/tests/layouts/header-overflow.stories.jsx @@ -0,0 +1,90 @@ +import React from 'react' +import { within, waitFor, expect } from 'storybook/test' +import { Box, Stack, SvgIcon, Typography } from '@mui/material' +import { Mail, Fingerprint, CalendarToday } from '@mui/icons-material' +import { CippCopyToClipBoard } from '../../src/components/CippComponents/CippCopyToClipboard' +import { shrinkToPhoneViewport } from '../viewport' + +/** + * Reproduces HeaderedTabbedLayout's mobile header markup — it cannot render the layout + * itself, which needs next/router and this Storybook runs on @storybook/react-vite. Keep the + * two in step: this exists to hold the CSS contract that lets a copy-chip truncate. + * + * A guest UPN is the worst case in the app: `user_domain.onmicrosoft.com#EXT#@tenant...` is + * one unbreakable token, roughly 60 characters, and it ran off the right edge of the screen. + */ +const GUEST_UPN = 'jduprey_7ngn50.onmicrosoft.com#EXT#@1h81wz.onmicrosoft.com' + +const SubtitleItem = ({ icon, children }) => ( + + + {icon} + + + {children} + + +) + +export default { + title: 'Layouts/HeaderedTabbedLayout/MobileHeader', + tags: ['autodocs'], +} + +export const GuestUpnDoesNotSpill = { + render: () => ( + + + + + + jduprey + + + + + }> + + + }> + + + }>Created: 1 month ago + + + + ), + play: async ({ canvasElement, step }) => { + const onAPhone = await shrinkToPhoneViewport() + if (!onAPhone) return + const canvas = within(canvasElement) + + await step('the guest UPN chip truncates instead of widening the page', async () => { + const host = canvasElement.querySelector('[data-testid="header-host"]') + await waitFor(() => expect(host.scrollWidth).toBeLessThanOrEqual(host.clientWidth)) + await expect(document.documentElement.scrollWidth).toBeLessThanOrEqual( + document.documentElement.clientWidth + ) + }) + + await step('and it is still the full value on the clipboard, not a truncated one', async () => { + // the label is elided in CSS only — the text node keeps the whole UPN + await expect(canvas.getByText(GUEST_UPN)).toBeInTheDocument() + }) + }, +} diff --git a/tests/layouts/notification-badge.stories.jsx b/tests/layouts/notification-badge.stories.jsx new file mode 100644 index 000000000000..5287412947fb --- /dev/null +++ b/tests/layouts/notification-badge.stories.jsx @@ -0,0 +1,92 @@ +import React from 'react' +import { within, waitFor, expect } from 'storybook/test' +import { Avatar, Badge, IconButton, Stack, SvgIcon } from '@mui/material' +import BellIcon from '@heroicons/react/24/outline/BellIcon' +import { shrinkToPhoneViewport, growToDesktopViewport } from '../viewport' + +/** + * The top bar's right-hand cluster, reproduced — `TopNav` itself pulls in the router, the + * tenant list and half a dozen API hooks. Keep this in step with `notifications-popover.js` + * and `top-nav.js`; it exists to hold one thing, which is that the notification dot belongs + * to the bell and not to the avatar beside it. + */ +const Cluster = ({ mobile }) => ( + + + + + + + + + + J + + +) + +export default { + title: 'Layouts/TopNav/NotificationBadge', + tags: ['autodocs'], +} + +const dotAndAvatar = (canvasElement) => ({ + bell: canvasElement.querySelector('.MuiBadge-root'), + dot: canvasElement.querySelector('.MuiBadge-badge'), + avatar: canvasElement.querySelector('[data-testid="account-avatar"]'), +}) + +export const DotStaysWithTheBellOnAPhone = { + render: () => , + play: async ({ canvasElement, step }) => { + const onAPhone = await shrinkToPhoneViewport() + if (!onAPhone) return + const { bell, dot, avatar } = dotAndAvatar(canvasElement) + + await step('the dot sits inside the bell, not over the gap to the avatar', async () => { + await waitFor(() => { + const d = dot.getBoundingClientRect() + const b = bell.getBoundingClientRect() + const a = avatar.getBoundingClientRect() + expect(d.right).toBeLessThanOrEqual(b.right + 0.5) + expect(d.top).toBeGreaterThanOrEqual(b.top - 0.5) + // and there is real space left between it and the avatar + expect(a.left - d.right).toBeGreaterThan(4) + }) + }) + }, +} + +// The md values are MUI's own, so the badge keeps hanging off the corner above the breakpoint. +export const DotKeepsItsCornerOnDesktop = { + render: () => , + play: async ({ canvasElement, step }) => { + const onDesktop = await growToDesktopViewport() + if (!onDesktop) return + const { bell, dot } = dotAndAvatar(canvasElement) + + await step('the dot still overhangs the button', async () => { + await waitFor(() => { + const d = dot.getBoundingClientRect() + const b = bell.getBoundingClientRect() + expect(d.right).toBeGreaterThan(b.right) + }) + }) + }, +} diff --git a/tests/lint/mobile-layout-patterns.test.js b/tests/lint/mobile-layout-patterns.test.js new file mode 100644 index 000000000000..30fe14b26f3e --- /dev/null +++ b/tests/lint/mobile-layout-patterns.test.js @@ -0,0 +1,253 @@ +import { describe, it, expect } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +// Two MUI patterns account for nearly every mobile layout bug in this app, and both are +// invisible on a desktop screen — so they ship freely and only surface as a phone report. +// This walks src/ and fails on either, which is cheaper than finding them one at a time. +// +// 1. / size={{ xs: N }} with N < 12 holds a desktop column split at 390px. +// 2. A Stack with flexWrap but no useFlexGap: MUI's `spacing` is a margin-left between +// children, and every wrapped row inherits it, so each new line starts indented. +// 3. A dashboard card pinned to a pixel height. That height exists to level two columns of +// a desktop grid; below lg the grid is a single column, so it levels nothing and clips +// instead — the Secure Score card lost its whole stats row off the bottom edge. + +const SRC = path.resolve(__dirname, "../../src"); + +// Dead Devias template code — nothing in pages/, components/ or layouts/ imports it. +const IGNORED_DIRS = new Set(["sections"]); + +const walk = (dir) => + fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + return IGNORED_DIRS.has(entry.name) ? [] : walk(full); + } + return /\.(js|jsx)$/.test(entry.name) ? [full] : []; + }); + +const rel = (file) => path.relative(SRC, file); + +// Commented-out JSX is not shipped markup — blank it (preserving newlines so reported +// line numbers stay accurate) rather than flagging code nobody renders. +const stripComments = (source) => + source + .replace(/\{\s*\/\*[\s\S]*?\*\/\s*\}/g, (m) => m.replace(/[^\n]/g, " ")) + .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")); + +/** Opening tags for `name`, brace-aware so multi-line JSX props stay in one string. */ +const openingTags = (source, name) => { + const tags = []; + const re = new RegExp(`<${name}\\b`, "g"); + let match; + while ((match = re.exec(source))) { + let depth = 0; + for (let i = match.index; i < source.length; i += 1) { + const char = source[i]; + if (char === "{") depth += 1; + else if (char === "}") depth -= 1; + else if (char === ">" && depth === 0) { + const text = source.slice(match.index, i + 1); + const line = source.slice(0, match.index).split("\n").length; + tags.push({ text, line, endLine: line + text.split("\n").length - 1 }); + break; + } + } + } + return tags; +}; + +// Not every fixed split is a bug — a tile can be designed to sit two-up at 390px. Marking +// the site opts it out, deliberately, in the source, next to the reason, where +// `rg mobile-layout-ok` finds every one of them. Read from the RAW source because comments +// are stripped before matching, and counted on the tag's own lines or the three above it, +// since JSX has nowhere to put a comment between props. +const MARKER = "mobile-layout-ok"; +const LOOKBACK = 3; + +const isExempt = (marked, tag) => { + for (let line = tag.line - LOOKBACK; line <= tag.endLine; line += 1) { + if (marked.has(line)) return true; + } + return false; +}; + +/** Grid splits that survive a phone, as `line reason` strings. */ +export const gridOffenders = (rawSource) => { + const source = stripComments(rawSource); + const marked = new Set(); + rawSource.split("\n").forEach((text, index) => { + if (text.includes(MARKER)) marked.add(index + 1); + }); + + const offenders = []; + for (const tag of openingTags(source, "Grid")) { + if (isExempt(marked, tag)) continue; + const bare = tag.text.match(/\bsize=\{(\d+(?:\.\d+)?)\}/); + if (bare && Number(bare[1]) !== 12) { + offenders.push(`${tag.line} size={${bare[1]}}`); + } + const xs = tag.text.match(/\bsize=\{\{[^}]*?\bxs:\s*(\d+(?:\.\d+)?)/); + if (xs && Number(xs[1]) < 12) { + offenders.push(`${tag.line} xs: ${xs[1]}`); + } + // v1 props are silently inert under Grid v2 — the split never applied at all + if (/]*\bxs=\{/.test(tag.text)) { + offenders.push(`${tag.line} legacy xs= prop (inert under Grid v2)`); + } + } + return offenders; +}; + +/** + * Percent-width column splits on Box/Stack, as `line reason` strings. The flexbox sibling + * of the Grid rule above: `` beside `` holds a desktop + * split at 390px too — the role editor's summary pane sat off the right edge of a phone + * this way. A responsive object (`width={{ xs: "100%", xl: "30%" }}`) passes. + */ +export const percentSplitOffenders = (rawSource) => { + const source = stripComments(rawSource); + const marked = new Set(); + rawSource.split("\n").forEach((text, index) => { + if (text.includes(MARKER)) marked.add(index + 1); + }); + + const offenders = []; + for (const name of ["Box", "Stack"]) { + for (const tag of openingTags(source, name)) { + if (isExempt(marked, tag)) continue; + const percent = tag.text.match(/\bwidth=\{?"(\d{1,2})%"\}?/); + if (percent) offenders.push(`${tag.line} width="${percent[1]}%"`); + } + } + return offenders; +}; + +/** Dashboard card wrappers pinned to a pixel height, as `line reason` strings. */ +export const pinnedHeightOffenders = (rawSource) => { + const source = stripComments(rawSource); + const marked = new Set(); + rawSource.split("\n").forEach((text, index) => { + if (text.includes(MARKER)) marked.add(index + 1); + }); + + const offenders = []; + for (const tag of openingTags(source, "Box")) { + if (isExempt(marked, tag)) continue; + // `height: 450` — a bare number. `height: { xs: 'auto', lg: 450 }` is the fix, and + // minHeight/maxHeight are constraints rather than a pin, so both are left alone. + const pinned = tag.text.match(/[^a-zA-Z]height:\s*(\d+)\s*[,}]/); + if (pinned) offenders.push(`${tag.line} height: ${pinned[1]}`); + } + return offenders; +}; + +const files = walk(SRC); +const dashboardFiles = files.filter((file) => rel(file).startsWith(path.join("pages", "dashboardv2"))); + +describe("mobile layout patterns", () => { + it("has files to check", () => { + expect(files.length).toBeGreaterThan(100); + }); + + it("declares no Grid column split that survives a phone", () => { + const offenders = files.flatMap((file) => + gridOffenders(fs.readFileSync(file, "utf8")).map((offender) => `${rel(file)}:${offender}`) + ); + expect(offenders, `Use size={{ xs: 12, sm|md: N }} instead:\n${offenders.join("\n")}`).toEqual( + [] + ); + }); + + it("takes a marked split at its word", () => { + const split = " \n"; + expect(gridOffenders(split)).toEqual(["1 xs: 6"]); + // on a line above, which is the only place JSX leaves room for one + expect(gridOffenders(` // two-up by design: ${MARKER}\n${split}`)).toEqual([]); + // or among the props of a tag spanning several lines + expect( + gridOffenders(` \n`) + ).toEqual([]); + // but a marker further up the file does not blanket the rest of it + expect(gridOffenders(` // ${MARKER}\n\n\n\n\n${split}`)).toEqual(["6 xs: 6"]); + }); + + it("declares no percent-width flex split that survives a phone", () => { + const offenders = files.flatMap((file) => + percentSplitOffenders(fs.readFileSync(file, "utf8")).map( + (offender) => `${rel(file)}:${offender}` + ) + ); + expect( + offenders, + `A percent width on Box/Stack holds a desktop split at 390px. Use width={{ xs: "100%", md|xl: "N%" }} or a Grid:\n${offenders.join("\n")}` + ).toEqual([]); + }); + + it("reads a percent split only as a fixed string width", () => { + expect(percentSplitOffenders(` \n`)).toEqual(['1 width="30%"']); + expect(percentSplitOffenders(` \n`)).toEqual(['1 width="80%"']); + expect(percentSplitOffenders(` \n`)).toEqual([]); + expect(percentSplitOffenders(` \n`)).toEqual([]); + expect(percentSplitOffenders(` \n`)).toEqual([]); + expect(percentSplitOffenders(` // ${MARKER}\n \n`)).toEqual([]); + }); + + it("pins no dashboard card to a pixel height", () => { + expect(dashboardFiles.length).toBeGreaterThan(0); + const offenders = dashboardFiles.flatMap((file) => + pinnedHeightOffenders(fs.readFileSync(file, "utf8")).map( + (offender) => `${rel(file)}:${offender}` + ) + ); + expect( + offenders, + `Below lg the dashboard is one column, so a fixed height only clips. Use height: { xs: 'auto', lg: N }:\n${offenders.join("\n")}` + ).toEqual([]); + }); + + it("reads a pinned height only as a bare number", () => { + expect(pinnedHeightOffenders(` \n`)).toEqual(["1 height: 450"]); + expect(pinnedHeightOffenders(` \n`)).toEqual([]); + expect(pinnedHeightOffenders(` \n`)).toEqual([]); + expect(pinnedHeightOffenders(` \n`)).toEqual([]); + expect(pinnedHeightOffenders(` // ${MARKER}\n \n`)).toEqual([]); + }); + + // Side nav, the drawer that replaces it, the hamburger that opens the drawer and the content + // gutter are four gates on one decision. Any of them declaring its own query lets them + // disagree, and a width with no side nav and no way to open the drawer has no nav at all. + it("keys layout chrome off the shared breakpoint hook, not its own media query", () => { + const offenders = []; + for (const name of ["index.js", "top-nav.js"]) { + const source = stripComments(fs.readFileSync(path.join(SRC, "layouts", name), "utf8")); + source.split("\n").forEach((line, i) => { + if (/useMediaQuery\(.*breakpoints\.(down|up|between)\(/.test(line)) { + offenders.push(`layouts/${name}:${i + 1}`); + } + }); + } + expect( + offenders, + `Nav gates have to agree. Use useIsMobileLayout from hooks/use-breakpoint:\n${offenders.join("\n")}` + ).toEqual([]); + }); + + it("gives every wrapping Stack useFlexGap", () => { + const offenders = []; + for (const file of files) { + const source = stripComments(fs.readFileSync(file, "utf8")); + if (!source.includes("flexWrap")) continue; + for (const { text, line } of openingTags(source, "Stack")) { + if (!text.includes("flexWrap") || text.includes("useFlexGap")) continue; + if (/flexWrap[=:]\s*[{'"\s]*nowrap/.test(text)) continue; + offenders.push(`${rel(file)}:${line}`); + } + } + expect( + offenders, + `Stack spacing is a margin that wrapped rows inherit — add useFlexGap:\n${offenders.join("\n")}` + ).toEqual([]); + }); +}); diff --git a/tests/pages/MessageEncryptionPage.test.jsx b/tests/pages/MessageEncryptionPage.test.jsx new file mode 100644 index 000000000000..c378ae2bb994 --- /dev/null +++ b/tests/pages/MessageEncryptionPage.test.jsx @@ -0,0 +1,136 @@ +import React from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithProviders } from '../test-utils' +import Page from '../../src/pages/email/tools/message-encryption/index.js' + +vi.mock('../../src/api/ApiCall', async () => + (await import('../mocks/api-call')).apiCallMock() +) +import { api, getResult, paginatedResult, postResult } from '../mocks/api-call' + +const AZURE_RMS = 'https://5c6bb73b-1234.rms.na.aadrm.com/_wmcs/licensing' +const AD_RMS = 'https://rms.contoso.local/_wmcs/licensing' + +// stable identity per the mock's own warning: a fresh literal per call spins the +// effects that key off the data object +const irmConfig = (overrides = {}) => ({ + AzureRMSLicensingEnabled: true, + InternalLicensingEnabled: true, + ExternalLicensingEnabled: false, + SimplifiedClientAccessEnabled: false, + TransportDecryptionSetting: 'Optional', + JournalReportDecryptionEnabled: true, + LicensingLocation: [AZURE_RMS], + MessageEncryptionEnabled: true, + AdRmsDetected: false, + ...overrides, +}) + +describe('Message Encryption page', () => { + beforeEach(() => { + vi.clearAllMocks() + api.post = postResult() + api.paginated = paginatedResult([ + { displayName: 'Admin', UPN: 'admin@contoso.com' }, + { displayName: 'Helpdesk', UPN: 'helpdesk@contoso.com' }, + ]) + }) + + it('renders the current IRM state for the tenant', async () => { + api.get = getResult({ data: irmConfig() }) + renderWithProviders() + + expect(await screen.findByText('Current Configuration')).toBeInTheDocument() + expect(screen.getByText('Enabled')).toBeInTheDocument() + expect(screen.getByText(AZURE_RMS)).toBeInTheDocument() + }) + + it('hides the migration warning for a cloud-only tenant', async () => { + api.get = getResult({ data: irmConfig() }) + renderWithProviders() + + await screen.findByText('Current Configuration') + expect(screen.queryByText(/not compatible with/i)).not.toBeInTheDocument() + }) + + it('warns that AD RMS has to be migrated before message encryption can be used', async () => { + api.get = getResult({ + data: irmConfig({ + AzureRMSLicensingEnabled: false, + MessageEncryptionEnabled: false, + LicensingLocation: [AD_RMS], + AdRmsDetected: true, + }), + }) + renderWithProviders() + + expect(await screen.findByText(/not compatible with/i)).toBeInTheDocument() + expect( + screen.getByRole('link', { name: 'migrated to Azure RMS' }) + ).toBeInTheDocument() + }) + + it('keeps Run Test disabled until both mailboxes are selected', async () => { + const user = userEvent.setup() + api.get = getResult({ data: irmConfig() }) + renderWithProviders() + + const runTest = await screen.findByRole('button', { name: 'Run Test' }) + expect(runTest).toBeDisabled() + + await user.click(screen.getByRole('combobox', { name: 'Sender' })) + await user.click( + await screen.findByRole('option', { name: 'Admin (admin@contoso.com)' }) + ) + expect(runTest).toBeDisabled() + + await user.click(screen.getByRole('combobox', { name: 'Recipient' })) + await user.click( + await screen.findByRole('option', { + name: 'Helpdesk (helpdesk@contoso.com)', + }) + ) + expect(runTest).toBeEnabled() + }) + + it('posts the Test action with the entered addresses', async () => { + const user = userEvent.setup() + api.get = getResult({ data: irmConfig() }) + renderWithProviders() + + await user.click(await screen.findByRole('combobox', { name: 'Sender' })) + await user.click( + await screen.findByRole('option', { name: 'Admin (admin@contoso.com)' }) + ) + await user.click(screen.getByRole('combobox', { name: 'Recipient' })) + await user.click( + await screen.findByRole('option', { + name: 'Helpdesk (helpdesk@contoso.com)', + }) + ) + await user.click(screen.getByRole('button', { name: 'Run Test' })) + + expect(api.post.mutate).toHaveBeenCalledWith({ + url: '/api/ExecIRMConfiguration', + data: { + tenantFilter: 'testdomain.com', + Action: 'Test', + Sender: 'admin@contoso.com', + Recipient: 'helpdesk@contoso.com', + }, + }) + }) + + it('surfaces a load failure', async () => { + api.get = getResult({ isSuccess: false, isError: true, data: undefined }) + renderWithProviders() + + expect( + await screen.findByText(/Failed to load the IRM configuration/i) + ).toBeInTheDocument() + // no card, otherwise every undefined field renders as a confident "Disabled"/"No" + expect(screen.queryByText('Current Configuration')).not.toBeInTheDocument() + }) +}) diff --git a/tests/pages/WorkerHealthPage.test.jsx b/tests/pages/WorkerHealthPage.test.jsx index 70233904da90..7f8bf6a8f392 100644 --- a/tests/pages/WorkerHealthPage.test.jsx +++ b/tests/pages/WorkerHealthPage.test.jsx @@ -1,6 +1,6 @@ import React from 'react' import { describe, it, expect, vi, beforeEach } from 'vitest' -import { screen, waitFor } from '@testing-library/react' +import { screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { renderWithProviders } from '../test-utils' import Page from '../../src/pages/cipp/advanced/container-management/worker-health.js' @@ -8,6 +8,7 @@ import Page from '../../src/pages/cipp/advanced/container-management/worker-heal vi.mock('../../src/api/ApiCall', async () => (await import('../mocks/api-call')).apiCallMock()) import { api, getResult, paginatedResult, postResult } from '../mocks/api-call' import { ApiGetCallWithPagination } from '../../src/api/ApiCall' +import { resetOverlayHistory } from '../../src/utils/overlay-history' // stable refs, see GraphExplorerPage.test.jsx (fresh literals per call loop the data-sync effects) const jobsResult = paginatedResult([ @@ -51,6 +52,68 @@ describe('Worker Health page - job queue preset filters', () => { }) }) + // Craft marks stale queue entries (task gone by dispatch time) as Skipped — same + // server-side filter contract as every other status. + it('Skipped toggle requests server-side filtering via the Status param', async () => { + const user = userEvent.setup() + renderWithProviders() + await screen.findByText('1-5 of 5') + + await user.click(screen.getByRole('button', { name: 'Skipped' })) + + await waitFor(() => { + const last = ApiGetCallWithPagination.mock.calls.at(-1)[0] + expect(last.queryKey).toBe('WorkerHealthJobs-2000-Skipped') + expect(last.data).toMatchObject({ Action: 'Jobs', Limit: '2000', Status: 'Skipped' }) + }) + }) + + // jsdom has no layout engine, so MRT's virtualized table renders no cells — drive the + // card view instead, where tapping a card opens the off-canvas (see CippDataTable.test.jsx). + it('opening a job card shows the off-canvas detail fields', async () => { + const cache = new Map() + window.matchMedia = (query) => { + if (!cache.has(query)) { + cache.set(query, { + matches: query.includes('max-width'), + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }) + } + return cache.get(query) + } + try { + const user = userEvent.setup() + renderWithProviders() + + await waitFor(() => expect(screen.getByText('Job Five')).toBeInTheDocument()) + await user.click(screen.getByText('Job Five')) + + // Drawer title is the job name; scope assertions to the drawer since the card + // behind it renders some of the same text. + const drawer = await waitFor(() => { + const d = screen + .getAllByText('Job Five') + .map((el) => el.closest('.MuiDrawer-paper')) + .find(Boolean) + expect(d).toBeTruthy() + return d + }) + // Started Utc is not a table column, and the Id value is hidden from the table. + // Job Five never started, so its StartedUtc renders as N/A. + expect(within(drawer).getByText('Started Utc')).toBeInTheDocument() + expect(within(drawer).getByText('j5')).toBeInTheDocument() + } finally { + resetOverlayHistory() + delete window.matchMedia + } + }, 30000) // card list mount + drawer transition; default 5000ms testTimeout flakes under load (see GraphExplorerPage) + it('All toggle drops the Status param instead of sending an empty string', async () => { const user = userEvent.setup() renderWithProviders() diff --git a/tests/theme/input-zoom.test.js b/tests/theme/input-zoom.test.js new file mode 100644 index 000000000000..4a47e93e3e84 --- /dev/null +++ b/tests/theme/input-zoom.test.js @@ -0,0 +1,16 @@ +import { describe, it, expect } from "vitest"; +import { createTheme } from "../../src/theme"; + +// iOS Safari zooms the viewport when a focused input renders text below 16px, and it does +// not zoom back out afterwards. Every MUI input must reach 16px on coarse pointers. +const COARSE = "@media (pointer: coarse)"; + +describe("input font size on touch devices", () => { + const theme = createTheme({ colorPreset: "orange", contrast: "high", paletteMode: "light" }); + + it.each(["MuiInputBase", "MuiFilledInput"])("%s inputs reach 16px on coarse pointers", (key) => { + const input = theme.components[key].styleOverrides.input; + expect(input.fontSize).toBeLessThan(16); // pointer devices stay compact + expect(input[COARSE]?.fontSize).toBe(16); + }); +}); diff --git a/tests/theme/mobile-gutters.test.js b/tests/theme/mobile-gutters.test.js new file mode 100644 index 000000000000..bf17a4fd4c6b --- /dev/null +++ b/tests/theme/mobile-gutters.test.js @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest"; +import { createTheme } from "../../src/theme"; + +// Card gutters are set once in the theme and paid at every nesting level: a card inside an +// accordion inside a page card spends most of a phone's width on chrome before any content +// gets a pixel. These have to stay narrower below md — and desktop has to keep its 24px. +const MOBILE = "@media (max-width: 899.95px)"; + +describe("horizontal gutters on small screens", () => { + const theme = createTheme({ colorPreset: "orange", contrast: "high", paletteMode: "light" }); + const root = (key) => theme.components[key].styleOverrides.root; + + it.each(["MuiCardContent", "MuiCardHeader", "MuiCardActions"])( + "%s trims its 24px gutters on a phone", + (key) => { + expect(root(key).paddingLeft).toBe(24); + expect(root(key)[MOBILE]?.paddingLeft).toBe(16); + expect(root(key)[MOBILE]?.paddingRight).toBe(16); + } + ); + + it.each(["MuiAccordionSummary", "MuiAccordionDetails"])( + "%s halves the padding it adds inside a card", + (key) => { + expect(root(key)[MOBILE]?.paddingLeft).toBe(8); + expect(root(key)[MOBILE]?.paddingRight).toBe(8); + } + ); + + // `:first-of-type` counts per element type, so an actions row of [caption div, button, + // button] gave the first button no margin and the second 16px. Invisible in a row; once the + // row stacks on a phone the two buttons sit at different left edges and different widths. + it("spaces dialog actions with gap on a phone, not a margin the stack inherits", () => { + const actions = root("MuiDialogActions"); + expect(actions["&>:not(:first-of-type)"].marginLeft).toBe(16); + expect(actions[MOBILE]?.["&>:not(:first-of-type)"]?.marginLeft).toBe(0); + expect(actions[MOBILE]?.gap).toBe(8); + expect(actions[MOBILE]?.paddingLeft).toBe(16); + }); + + it("leaves vertical rhythm alone — width is what runs out, not height", () => { + const content = root("MuiCardContent"); + expect(content.paddingTop).toBe(20); + expect(content[MOBILE]?.paddingTop).toBeUndefined(); + expect(content[MOBILE]?.paddingBottom).toBeUndefined(); + }); +}); diff --git a/tests/theme/tooltip-touch.test.jsx b/tests/theme/tooltip-touch.test.jsx new file mode 100644 index 000000000000..d7b306af5668 --- /dev/null +++ b/tests/theme/tooltip-touch.test.jsx @@ -0,0 +1,56 @@ +import React from "react"; +import { describe, it, expect } from "vitest"; +import { screen, fireEvent, waitFor } from "@testing-library/react"; +import { Tooltip, Button } from "@mui/material"; +import { createTheme } from "../../src/theme"; +import { renderWithTheme } from "../test-utils"; + +// MUI's Tooltip attaches no touchmove and no scroll listener: handleTouchStart arms a 700ms +// timer that opens the tooltip, and only handleTouchEnd schedules the close. A press held +// through a scroll therefore opens one and nothing closes it while the finger is down. +describe("tooltips on touch", () => { + it("is disabled by default across the app", () => { + const theme = createTheme({ colorPreset: "orange", contrast: "high", paletteMode: "light" }); + expect(theme.components.MuiTooltip.defaultProps.disableTouchListener).toBe(true); + }); + + // Real timers: MUI arms enterDelay inside the enterTouchDelay callback, and the nested + // pair does not advance reliably under fake ones — a faked version of this test passed + // with the fix removed, which is worse than no test. + it("does not open from a long press", async () => { + renderWithTheme( + + + + ); + + fireEvent.touchStart(screen.getByRole("button")); + await new Promise((resolve) => setTimeout(resolve, 400)); + + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + + it("still opens on hover, where a tooltip belongs", async () => { + renderWithTheme( + + + + ); + + fireEvent.mouseOver(screen.getByRole("button")); + + expect(await screen.findByRole("tooltip")).toHaveTextContent("Users in this tenant"); + }); + + it("lets a site opt back in", async () => { + renderWithTheme( + + + + ); + + fireEvent.touchStart(screen.getByRole("button")); + + await waitFor(() => expect(screen.getByRole("tooltip")).toBeInTheDocument()); + }); +}); diff --git a/tests/utils/csv-field-values.test.js b/tests/utils/csv-field-values.test.js new file mode 100644 index 000000000000..027262e2be95 --- /dev/null +++ b/tests/utils/csv-field-values.test.js @@ -0,0 +1,68 @@ +import { + extractCsvColumnValues, + mergeCsvFormFields, + normalizeAutoCompleteValues, +} from '../../src/utils/csv-field-values' + +describe('csv-field-values', () => { + describe('extractCsvColumnValues', () => { + it('extracts values for a matching column (case-insensitive, trimmed header)', () => { + const rows = [ + { userPrincipalName: 'a@contoso.com' }, + { ' UserPrincipalName ': 'b@contoso.com' }, + { other: 'skip' }, + ] + expect(extractCsvColumnValues(rows, 'userPrincipalName')).toEqual([ + 'a@contoso.com', + 'b@contoso.com', + ]) + }) + + it('returns empty when the column header is missing', () => { + const rows = [{ 'User Principal Name': 'a@contoso.com' }] + expect(extractCsvColumnValues(rows, 'userPrincipalName')).toEqual([]) + }) + }) + + describe('normalizeAutoCompleteValues', () => { + it('flattens {label,value} objects to string values', () => { + expect( + normalizeAutoCompleteValues([ + { label: 'Alice', value: 'id-1' }, + { label: 'Bob', value: 'id-2' }, + ]) + ).toEqual(['id-1', 'id-2']) + }) + }) + + describe('mergeCsvFormFields', () => { + const fields = [ + { type: 'autoComplete', name: 'users', csvColumn: 'userPrincipalName' }, + ] + + it('merges autocomplete and CSV values and drops the companion field', () => { + const merged = mergeCsvFormFields( + { + users: [{ label: 'Alice', value: 'id-1' }], + users__csv: [{ userPrincipalName: 'csv@contoso.com' }], + }, + fields + ) + expect(merged).toEqual({ + users: ['id-1', 'csv@contoso.com'], + }) + }) + + it('yields an empty users array when CSV rows lack the configured column', () => { + const merged = mergeCsvFormFields( + { + users: [], + users__csv: [{ 'User Principal Name': 'a@contoso.com' }], + }, + fields + ) + expect(merged.users).toEqual([]) + expect(merged.users__csv).toBeUndefined() + }) + }) +}) diff --git a/tests/utils/get-filtered-portals.test.js b/tests/utils/get-filtered-portals.test.js new file mode 100644 index 000000000000..bbd1fbc0ad17 --- /dev/null +++ b/tests/utils/get-filtered-portals.test.js @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { getFilteredPortals } from "../../src/utils/get-filtered-portals"; +import Portals from "../../src/data/portals"; + +const names = (portals) => portals.map((p) => p.name); + +// Pre-existing mismatch, documented rather than fixed here: portals.json splits Power +// Platform into _Admin/_Maker entries, while the defaults map (and the preferences toggle, +// and dashboardv1) still key on the un-suffixed Power_Platform_Portal — so those two are +// filtered out for everyone and their preference toggle controls nothing. +const UNREACHABLE_BY_DEFAULT = ["Power_Platform_Portal_Admin", "Power_Platform_Portal_Maker"]; +const defaultVisible = names(Portals).filter((n) => !UNREACHABLE_BY_DEFAULT.includes(n)); + +describe("getFilteredPortals", () => { + it("returns every default-on portal when settings carry no preferences", () => { + expect(names(getFilteredPortals({}))).toEqual(defaultVisible); + }); + + it("tolerates undefined settings", () => { + expect(names(getFilteredPortals(undefined))).toEqual(defaultVisible); + }); + + it("hides a portal turned off in UserSpecificSettings", () => { + const result = getFilteredPortals({ + UserSpecificSettings: { portalLinks: { Exchange_Portal: false } }, + }); + + expect(names(result)).not.toContain("Exchange_Portal"); + expect(names(result)).toContain("M365_Portal"); + }); + + it("falls back to tenant-level portalLinks when no user-specific ones exist", () => { + const result = getFilteredPortals({ portalLinks: { Azure_Portal: false } }); + + expect(names(result)).not.toContain("Azure_Portal"); + expect(names(result)).toContain("M365_Portal"); + }); + + it("prefers UserSpecificSettings over tenant-level portalLinks", () => { + const result = getFilteredPortals({ + portalLinks: { Teams_Portal: false }, + UserSpecificSettings: { portalLinks: { Entra_Portal: false } }, + }); + + // The user-specific object wins outright — the tenant-level opt-out is not merged in. + expect(names(result)).toContain("Teams_Portal"); + expect(names(result)).not.toContain("Entra_Portal"); + }); +}); diff --git a/tests/utils/impersonation.test.js b/tests/utils/impersonation.test.js new file mode 100644 index 000000000000..2c6dda9c046e --- /dev/null +++ b/tests/utils/impersonation.test.js @@ -0,0 +1,119 @@ +import { + getImpersonatedRole, + subscribeImpersonation, + enterImpersonation, + exitImpersonation, + impersonationCacheParams, +} from '../../src/utils/impersonation' + +const KEY = 'cipp_impersonate_role' + +describe('impersonation store', () => { + let reloadSpy + + beforeEach(() => { + window.localStorage.clear() + // jsdom's location.reload is not configurable via vi.spyOn directly + reloadSpy = vi.fn() + Object.defineProperty(window, 'location', { + value: { ...window.location, reload: reloadSpy }, + writable: true, + }) + }) + + it('is null by default and reflects the stored role', () => { + expect(getImpersonatedRole()).toBeNull() + window.localStorage.setItem(KEY, 'helpdesk') + expect(getImpersonatedRole()).toBe('helpdesk') + }) + + it('enterImpersonation lowercases, stores, clears caches and reloads', () => { + window.localStorage.setItem('REACT_QUERY_OFFLINE_CACHE', 'x') + window.localStorage.setItem('REACT_QUERY_OFFLINE_CACHE_extra', 'y') + window.localStorage.setItem('app.settings', 'keep-me') + const queryClient = { clear: vi.fn() } + + enterImpersonation('HelpDesk', queryClient) + + expect(window.localStorage.getItem(KEY)).toBe('helpdesk') + expect(queryClient.clear).toHaveBeenCalledTimes(1) + expect(window.localStorage.getItem('REACT_QUERY_OFFLINE_CACHE')).toBeNull() + expect(window.localStorage.getItem('REACT_QUERY_OFFLINE_CACHE_extra')).toBeNull() + expect(window.localStorage.getItem('app.settings')).toBe('keep-me') + expect(reloadSpy).toHaveBeenCalledTimes(1) + }) + + it('exitImpersonation removes the key, clears caches and reloads', () => { + window.localStorage.setItem(KEY, 'helpdesk') + window.localStorage.setItem('REACT_QUERY_OFFLINE_CACHE', 'x') + const queryClient = { clear: vi.fn() } + + exitImpersonation(queryClient) + + expect(window.localStorage.getItem(KEY)).toBeNull() + expect(window.localStorage.getItem('REACT_QUERY_OFFLINE_CACHE')).toBeNull() + expect(reloadSpy).toHaveBeenCalledTimes(1) + }) + + it('notifies subscribers on enter and exit, and unsubscribe works', () => { + const listener = vi.fn() + const unsubscribe = subscribeImpersonation(listener) + + enterImpersonation('readonly', { clear: vi.fn() }) + expect(listener).toHaveBeenCalledTimes(1) + + exitImpersonation({ clear: vi.fn() }) + expect(listener).toHaveBeenCalledTimes(2) + + unsubscribe() + enterImpersonation('editor', { clear: vi.fn() }) + expect(listener).toHaveBeenCalledTimes(2) + }) + + it('impersonationCacheParams segregates the Craft cache key only while impersonating', () => { + expect(impersonationCacheParams()).toEqual({}) + window.localStorage.setItem(KEY, 'helpdesk') + expect(impersonationCacheParams()).toEqual({ _imp: 'helpdesk' }) + }) + + it('survives a throwing localStorage without crashing', () => { + const original = window.localStorage + Object.defineProperty(window, 'localStorage', { + value: { + getItem: () => { + throw new Error('denied') + }, + setItem: () => { + throw new Error('denied') + }, + removeItem: () => { + throw new Error('denied') + }, + }, + configurable: true, + }) + + expect(getImpersonatedRole()).toBeNull() + expect(() => exitImpersonation({ clear: vi.fn() })).not.toThrow() + + Object.defineProperty(window, 'localStorage', { value: original, configurable: true }) + }) +}) + +describe('buildVersionedHeaders impersonation header', () => { + beforeEach(() => { + window.localStorage.clear() + global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ version: '1.0' }) }) + }) + + it('adds x-cipp-impersonate-role only while impersonating', async () => { + const { buildVersionedHeaders } = await import('../../src/utils/cippVersion') + + const plain = await buildVersionedHeaders() + expect(plain['x-cipp-impersonate-role']).toBeUndefined() + + window.localStorage.setItem(KEY, 'helpdesk') + const impersonated = await buildVersionedHeaders() + expect(impersonated['x-cipp-impersonate-role']).toBe('helpdesk') + }) +}) diff --git a/tests/utils/overlay-history.test.js b/tests/utils/overlay-history.test.js new file mode 100644 index 000000000000..a62820d5510a --- /dev/null +++ b/tests/utils/overlay-history.test.js @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + installOverlayHistory, + pushOverlayEntry, + releaseOverlayEntry, + resetOverlayHistory, +} from "../../src/utils/overlay-history"; + +// The shape Next's pages router keeps in history.state for the current route. +const routeState = (as) => ({ __N: true, url: as, as, key: `key-${as}`, options: {} }); + +// jsdom traverses asynchronously, same as a browser: back() queues the task and popstate +// lands later. Every assertion about a back press has to wait for it. +const nextPop = () => + new Promise((resolve) => window.addEventListener("popstate", resolve, { once: true })); + +const goBack = async () => { + const settled = nextPop(); + window.history.back(); + await settled; +}; + +// A browser fires a single popstate for a multi-entry jump, e.g. the long-press back menu. +const goTo = async (delta) => { + const settled = nextPop(); + window.history.go(delta); + await settled; +}; + +beforeEach(() => { + window.history.replaceState(routeState("/identity/users"), ""); +}); + +afterEach(() => { + resetOverlayHistory(); +}); + +describe("overlay history", () => { + it("closes the overlay on a back press instead of letting the page navigate", async () => { + const close = vi.fn(); + const url = window.location.href; + installOverlayHistory(); + pushOverlayEntry(close); + + // The entry sits at the same url — nothing about the page changed. + expect(window.location.href).toBe(url); + await goBack(); + + expect(close).toHaveBeenCalledTimes(1); + }); + + it("keeps the pushed entry recognisable to Next's router", () => { + installOverlayHistory(); + pushOverlayEntry(vi.fn()); + + // Cloning the router's own state is what makes this entry survive a navigation away and + // back: Next ignores any history entry without __N, and would leave the app on a blank + // route if it landed on one. + expect(window.history.state.__N).toBe(true); + expect(window.history.state.as).toBe("/identity/users"); + }); + + it("dismisses one overlay per back press, deepest first", async () => { + const closeOuter = vi.fn(); + const closeInner = vi.fn(); + installOverlayHistory(); + pushOverlayEntry(closeOuter); + pushOverlayEntry(closeInner); + + await goBack(); + expect(closeInner).toHaveBeenCalledTimes(1); + expect(closeOuter).not.toHaveBeenCalled(); + + await goBack(); + expect(closeOuter).toHaveBeenCalledTimes(1); + }); + + it("takes its history entry back when the overlay is closed by hand", async () => { + const close = vi.fn(); + installOverlayHistory(); + const entry = pushOverlayEntry(close); + + const settled = nextPop(); + releaseOverlayEntry(entry); + await settled; + + // The component closed itself, so the callback must not fire again — and the entry is + // gone, so the user's next back press belongs to the page. + expect(close).not.toHaveBeenCalled(); + expect(window.history.state.__cippOverlay).toBeUndefined(); + }); + + it("leaves history alone when its entry has been buried by a navigation", () => { + const back = vi.spyOn(window.history, "back"); + const close = vi.fn(); + installOverlayHistory(); + const entry = pushOverlayEntry(close); + + // A link inside the overlay navigated: Next pushed a route entry over ours. + window.history.pushState(routeState("/identity/users/user"), ""); + releaseOverlayEntry(entry); + + // Popping here would drag the user back off the page they just opened. + expect(back).not.toHaveBeenCalled(); + back.mockRestore(); + }); +}); + +describe("overlay history / Next router handoff", () => { + // Next's own popstate listener is registered at app boot, before ours, and calls + // beforePopState from inside it. Registering this listener before installOverlayHistory + // reproduces that ordering — which matters, because the answer depends on state our + // listener is about to overwrite. + const withRouter = () => { + const answers = []; + let handler = null; + const listener = (event) => { + if (handler) answers.push(handler(event.state)); + }; + window.addEventListener("popstate", listener); + installOverlayHistory({ + beforePopState: (cb) => { + handler = cb; + }, + }); + return { + answers, + teardown: () => window.removeEventListener("popstate", listener), + }; + }; + + it("stops Next from re-rendering the route when the pop was ours", async () => { + const router = withRouter(); + pushOverlayEntry(vi.fn()); + + await goBack(); + + // false means "handled downstream". Letting Next through would emit route events and + // reset scroll — a long list would jump to the top every time a row was dismissed. + expect(router.answers).toEqual([false]); + router.teardown(); + }); + + it("leaves ordinary back presses to Next", async () => { + const router = withRouter(); + window.history.pushState(routeState("/identity/users"), ""); + + await goBack(); + + expect(router.answers).toEqual([true]); + router.teardown(); + }); + + it("leaves a real navigation to Next even with an overlay open", async () => { + window.history.replaceState(routeState("/identity/devices"), ""); + window.history.pushState(routeState("/identity/users"), ""); + const router = withRouter(); + const close = vi.fn(); + pushOverlayEntry(close); + + // The long-press back menu jumps straight past our entry to another route. That pop + // lands on a different page, so Next has to run — and the overlay closes with the page + // it belonged to. + await goTo(-2); + + expect(router.answers).toEqual([true]); + expect(close).toHaveBeenCalledTimes(1); + router.teardown(); + }); +}); diff --git a/tests/utils/permission-rules.test.js b/tests/utils/permission-rules.test.js new file mode 100644 index 000000000000..a78e863be3c8 --- /dev/null +++ b/tests/utils/permission-rules.test.js @@ -0,0 +1,151 @@ +import { + matchPattern, + flattenPermissionTree, + expandRules, + rulesToFlatMap, + flatMapToRules, + validateRulePattern, + buildRuleSuggestions, +} from '../../src/utils/permission-rules' + +// Shape returned by /api/ExecAPIPermissionList: Cat -> Obj -> Read|ReadWrite -> functions +const apiPermissions = { + CIPP: { + Core: { Read: {}, ReadWrite: {} }, + }, + Identity: { + User: { Read: {}, ReadWrite: {} }, + Device: { Read: {}, ReadWrite: {} }, + }, + Exchange: { + Mailbox: { Read: {}, ReadWrite: {} }, + }, +} + +const universe = flattenPermissionTree(apiPermissions) + +describe('matchPattern', () => { + it('mirrors PowerShell -like: multiple wildcards all expand', () => { + // The old implementation only replaced the first *; this pattern needs both. + expect(matchPattern('CIPP.*.Read*', 'CIPP.Core.ReadWrite')).toBe(true) + expect(matchPattern('*.Mailbox.*', 'Exchange.Mailbox.Read')).toBe(true) + }) + + it('treats dots as literal separators, not regex wildcards', () => { + expect(matchPattern('Identity.User.Read', 'IdentityXUserXRead')).toBe(false) + expect(matchPattern('Identity.User.Read', 'Identity.User.Read')).toBe(true) + }) + + it('is case-insensitive like -like', () => { + expect(matchPattern('identity.user.*', 'Identity.User.ReadWrite')).toBe(true) + }) + + it('anchors the pattern to the whole string', () => { + expect(matchPattern('Identity.User', 'Identity.User.Read')).toBe(false) + expect(matchPattern('*.Read', 'Identity.User.ReadWrite')).toBe(false) + }) +}) + +describe('flattenPermissionTree', () => { + it('lists every Cat.Obj.Level string, sorted', () => { + expect(universe).toEqual([ + 'CIPP.Core.Read', + 'CIPP.Core.ReadWrite', + 'Exchange.Mailbox.Read', + 'Exchange.Mailbox.ReadWrite', + 'Identity.Device.Read', + 'Identity.Device.ReadWrite', + 'Identity.User.Read', + 'Identity.User.ReadWrite', + ]) + }) + + it('handles a missing tree', () => { + expect(flattenPermissionTree(undefined)).toEqual([]) + }) +}) + +describe('expandRules', () => { + it('grants includes minus excludes, exclude wins', () => { + const { matched, excludedBy } = expandRules( + { Include: ['Identity.*'], Exclude: ['Identity.Device.*'] }, + universe, + ) + expect(matched).toEqual(['Identity.User.Read', 'Identity.User.ReadWrite']) + expect(excludedBy['Identity.Device.Read']).toBe('Identity.Device.*') + }) + + it('reports per-pattern match counts for the live preview', () => { + const { includeCounts, excludeCounts } = expandRules( + { Include: ['*.Read', 'Identity.Uesr.*'], Exclude: ['CIPP.*'] }, + universe, + ) + expect(includeCounts['*.Read']).toBe(4) + // Typo'd pattern matches nothing — this is what powers the zero-match warning. + expect(includeCounts['Identity.Uesr.*']).toBe(0) + expect(excludeCounts['CIPP.*']).toBe(1) + }) + + it('accepts autocomplete option objects as rule entries', () => { + const { matched } = expandRules( + { Include: [{ label: 'Identity.User.Read', value: 'Identity.User.Read' }], Exclude: [] }, + universe, + ) + expect(matched).toEqual(['Identity.User.Read']) + }) +}) + +describe('rulesToFlatMap', () => { + it('produces the editor grid map with ReadWrite beating Read', () => { + const flat = rulesToFlatMap({ Include: ['Identity.User.*'], Exclude: [] }, apiPermissions) + expect(flat['IdentityUser']).toBe('Identity.User.ReadWrite') + expect(flat['IdentityDevice']).toBe('Identity.Device.None') + }) + + it('floors CIPP.Core at Read so a saved snapshot never locks out sign-in', () => { + const flat = rulesToFlatMap({ Include: ['Exchange.*'], Exclude: [] }, apiPermissions) + expect(flat['CIPPCore']).toBe('CIPP.Core.Read') + }) + + it('honours excludes', () => { + const flat = rulesToFlatMap( + { Include: ['Identity.*'], Exclude: ['Identity.User.ReadWrite'] }, + apiPermissions, + ) + expect(flat['IdentityUser']).toBe('Identity.User.Read') + }) +}) + +describe('flatMapToRules', () => { + it('converts a grid map to concrete-string rules, dropping None', () => { + expect( + flatMapToRules({ + IdentityUser: 'Identity.User.ReadWrite', + IdentityDevice: 'Identity.Device.None', + CIPPCore: 'CIPP.Core.Read', + }), + ).toEqual({ Include: ['CIPP.Core.Read', 'Identity.User.ReadWrite'], Exclude: [] }) + }) +}) + +describe('validateRulePattern', () => { + it.each(['*', '*.Read', 'Identity.*', 'Identity.User.*', 'Identity.User.ReadWrite'])( + 'accepts %s', + (pattern) => expect(validateRulePattern(pattern)).toBe(true), + ) + + it.each(['', 'Identity.User.Read.Extra', 'Identity User', 'Identity..Read', 'a.b.c;drop'])( + 'rejects %s', + (pattern) => expect(validateRulePattern(pattern)).toBe(false), + ) +}) + +describe('buildRuleSuggestions', () => { + it('offers global, category and concrete patterns', () => { + const values = buildRuleSuggestions(apiPermissions).map((o) => o.value) + expect(values).toContain('*') + expect(values).toContain('Identity.*') + expect(values).toContain('Identity.User.*') + expect(values).toContain('Identity.User.ReadWrite') + }) +}) diff --git a/tests/utils/resolve-row-templates.test.js b/tests/utils/resolve-row-templates.test.js new file mode 100644 index 000000000000..e89ef97dace5 --- /dev/null +++ b/tests/utils/resolve-row-templates.test.js @@ -0,0 +1,121 @@ +import { + getNestedValue, + resolveRowTemplates, + attachParentRow, + getRowTenant, +} from '../../src/utils/resolve-row-templates' + +const row = { + id: 'abc-123', + displayName: 'Finance', + siteId: 'site-1', + nested: { mail: 'finance@contoso.com' }, +} + +describe('getNestedValue', () => { + it('reads a top-level field', () => { + expect(getNestedValue(row, 'id')).toBe('abc-123') + }) + + it('reads a dotted path', () => { + expect(getNestedValue(row, 'nested.mail')).toBe('finance@contoso.com') + }) + + it('returns undefined for a missing path', () => { + expect(getNestedValue(row, 'missing.path')).toBeUndefined() + }) +}) + +describe('resolveRowTemplates', () => { + it('replaces [id] in a string', () => { + expect(resolveRowTemplates('group-members-[id]', row)).toBe( + 'group-members-abc-123' + ) + }) + + it('replaces a nested path', () => { + expect(resolveRowTemplates('mail=[nested.mail]', row)).toBe( + 'mail=finance@contoso.com' + ) + }) + + it('leaves an unmatched token in place', () => { + expect(resolveRowTemplates('x-[unknown]', row)).toBe('x-[unknown]') + }) + + it('walks objects used as api.data', () => { + expect( + resolveRowTemplates( + { someId: '[id]', extra: true, siteId: '[siteId]' }, + row + ) + ).toEqual({ someId: 'abc-123', extra: true, siteId: 'site-1' }) + }) + + it('leaves booleans and numbers alone', () => { + expect(resolveRowTemplates(true, row)).toBe(true) + expect(resolveRowTemplates(999, row)).toBe(999) + }) + + it('walks arrays', () => { + expect(resolveRowTemplates(['[id]', 1], row)).toEqual(['abc-123', 1]) + }) +}) + +describe('attachParentRow', () => { + const parentRow = { id: 'group-1', displayName: 'Finance' } + + it('attaches the opening row as parent', () => { + expect(attachParentRow({ id: 'member-1' }, parentRow)).toEqual({ + id: 'member-1', + parent: parentRow, + }) + }) + + it('leaves a row unchanged when there is no parent', () => { + const child = { id: 'member-1' } + expect(attachParentRow(child, undefined)).toBe(child) + }) + + it('maps arrays', () => { + expect(attachParentRow([{ id: 'a' }, { id: 'b' }], parentRow)).toEqual([ + { id: 'a', parent: parentRow }, + { id: 'b', parent: parentRow }, + ]) + }) + + it('chains an existing parent when the opening row is not nested', () => { + const child = { id: 'member-1', parent: { id: 'api-parent' } } + expect(attachParentRow(child, parentRow).parent).toEqual({ + id: 'group-1', + displayName: 'Finance', + parent: { id: 'api-parent' }, + }) + }) + + it('keeps a nested table chain instead of overwriting it', () => { + const nestedParent = { id: 'member-1', parent: parentRow } + const grandchild = { id: 'license-1' } + expect(attachParentRow(grandchild, nestedParent).parent).toBe(nestedParent) + }) +}) + +describe('getRowTenant', () => { + it('returns the current tenant outside AllTenants', () => { + expect( + getRowTenant({ Tenant: 'other.com' }, 'contoso.com') + ).toBe('contoso.com') + }) + + it('prefers the row tenant in AllTenants', () => { + expect(getRowTenant({ Tenant: 'child.com' }, 'AllTenants')).toBe( + 'child.com' + ) + }) + + it('falls back to the nested parent tenant', () => { + expect( + getRowTenant({ parent: { Tenant: 'parent.com' } }, 'AllTenants') + ).toBe('parent.com') + }) +}) diff --git a/tests/viewport.js b/tests/viewport.js new file mode 100644 index 000000000000..4a6dff2ce578 --- /dev/null +++ b/tests/viewport.js @@ -0,0 +1,41 @@ +/** + * Resizes the story iframe, for stories that measure layout or drive a breakpoint. + * + * Three things this exists to get right: + * - The VIEWPORT has to shrink, not a wrapper element. MUI breakpoints are media queries, + * so a 390px-wide Box inside a desktop-width iframe still renders every `md` branch. + * - The import has to be lazy. At module scope `@vitest/browser/context` throws + * "can be imported only inside the Browser Mode", which breaks the story for anyone who + * opens it in the Storybook app rather than the test runner. + * - Every story shares one page. A story that shrinks the viewport and never restores it + * leaves the next story running at phone width — which is an ordering-dependent failure, + * so a desktop story must claim its width rather than assume it. + * + * Returns false when there is no runner driving the iframe, so a play function can skip + * measurements that would otherwise assert against whatever width Storybook happens to use. + * + * NOTE: resizing does not synchronously re-render. `useMediaQuery` updates from a matchMedia + * change listener, i.e. a tick later — so the first assertion that depends on the new + * breakpoint must be a `findBy*` or wrapped in `waitFor`, never a bare `getBy*`. Verified by + * probe: right after this resolves, the mobile branch is not in the DOM yet. A preceding + * `await` on something present in BOTH branches does not settle it — it only makes the race + * usually go your way, which is how CippWizardPage passed locally and failed in CI. + */ +const resize = async (width, height) => { + try { + const { page } = await import("@vitest/browser/context"); + await page.viewport(width, height); + // Measured: window.innerWidth is ALREADY the new value when this resolves — the width is + // not what lags. What lags is React: matchMedia listeners fire, useMediaQuery setStates, + // and the breakpoint branch renders a tick later. A frame here covers the common case; it + // is not a guarantee, which is why callers must still findBy/waitFor (see below). + await new Promise((resolve) => requestAnimationFrame(resolve)); + return true; + } catch { + return false; + } +}; + +export const shrinkToPhoneViewport = async (width = 390, height = 844) => resize(width, height); + +export const growToDesktopViewport = async (width = 1280, height = 900) => resize(width, height); diff --git a/yarn.lock b/yarn.lock index cdccb617fe34..db0f5d11f0dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1102,6 +1102,22 @@ resolved "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz#798a33950d11226a0ebb6acafa60f5594424967f" integrity sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA== +"@emnapi/core@1.11.2": + version "1.11.2" + resolved "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz#fab0a0f3c492d11f5a9ac9065d0d73955ee1c1c9" + integrity sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA== + dependencies: + "@emnapi/wasi-threads" "1.2.2" + tslib "^2.4.0" + +"@emnapi/core@1.9.2": + version "1.9.2" + resolved "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz#3870265ecffc7352d01ead62d8d83d8358a2d034" + integrity sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA== + dependencies: + "@emnapi/wasi-threads" "1.2.1" + tslib "^2.4.0" + "@emnapi/core@^1.4.3": version "1.9.1" resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.9.1.tgz#2143069c744ca2442074f8078462e51edd63c7bd" @@ -1110,6 +1126,20 @@ "@emnapi/wasi-threads" "1.2.0" tslib "^2.4.0" +"@emnapi/runtime@1.11.2": + version "1.11.2" + resolved "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz#eb22f04d76febfdf4f87fdaff54c8a53f6bf0dbd" + integrity sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA== + dependencies: + tslib "^2.4.0" + +"@emnapi/runtime@1.9.2": + version "1.9.2" + resolved "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz#8b469a3db160817cadb1de9050211a9d1ea84fa2" + integrity sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw== + dependencies: + tslib "^2.4.0" + "@emnapi/runtime@^1.4.3", "@emnapi/runtime@^1.7.0": version "1.9.1" resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.9.1.tgz#115ff2a0d589865be6bd8e9d701e499c473f2a8d" @@ -1124,6 +1154,20 @@ dependencies: tslib "^2.4.0" +"@emnapi/wasi-threads@1.2.1": + version "1.2.1" + resolved "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz#28fed21a1ba1ce797c44a070abc94d42f3ae8548" + integrity sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w== + dependencies: + tslib "^2.4.0" + +"@emnapi/wasi-threads@1.2.2": + version "1.2.2" + resolved "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz#4c93becf5bfa3b13d1bbdcc06aee38321ad8139a" + integrity sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA== + dependencies: + tslib "^2.4.0" + "@emotion/babel-plugin@^11.13.5": version "11.13.5" resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz#eab8d65dbded74e0ecfd28dc218e75607c4e7bc0" @@ -1241,265 +1285,135 @@ resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz#5e13fac887f08c44f76b0ccaf3370eb00fec9bb6" integrity sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg== -"@esbuild/aix-ppc64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz#82b74f92aa78d720b714162939fb248c90addf53" - integrity sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg== - -"@esbuild/aix-ppc64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz#7a01a8d2ec2fbb2dac78adad09b0fa781e4082be" - integrity sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ== - -"@esbuild/android-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz#f78cb8a3121fc205a53285adb24972db385d185d" - integrity sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ== - -"@esbuild/android-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz#b540a27d14e4afd058496a4dbec4d3f414db110a" - integrity sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg== - -"@esbuild/android-arm@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz#593e10a1450bbfcac6cb321f61f468453bac209d" - integrity sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ== - -"@esbuild/android-arm@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz#704bd297de6d762de54eabbeafbf55f6756abe2f" - integrity sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ== - -"@esbuild/android-x64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz#453143d073326033d2d22caf9e48de4bae274b07" - integrity sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg== - -"@esbuild/android-x64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz#d1cb166d34b0fbf0fe8ab460a5594f24a378701e" - integrity sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng== - -"@esbuild/darwin-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz#6f23000fb9b40b7e04b7d0606c0693bd0632f322" - integrity sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw== - -"@esbuild/darwin-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz#1034b26457fc886368fe61bbd09f653f6afa8e54" - integrity sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q== - -"@esbuild/darwin-x64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz#27393dd18bb1263c663979c5f1576e00c2d024be" - integrity sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ== - -"@esbuild/darwin-x64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz#65556a432a1e4d72032d8218c1932fcca1a49772" - integrity sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ== - -"@esbuild/freebsd-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz#22e4638fa502d1c0027077324c97640e3adf3a62" - integrity sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w== - -"@esbuild/freebsd-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz#2e61e0592f9030d7e3dae18ee25ebc535918aef6" - integrity sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw== - -"@esbuild/freebsd-x64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz#9224b8e4fea924ce2194e3efc3e9aebf822192d6" - integrity sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ== - -"@esbuild/freebsd-x64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz#c95ec289959ef8079c4dca817a1e2c4be66b9bd3" - integrity sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ== - -"@esbuild/linux-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz#4f5d1c27527d817b35684ae21419e57c2bda0966" - integrity sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A== - -"@esbuild/linux-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz#40b22175dda06182f3ee8141186c5ff304c4a717" - integrity sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g== - -"@esbuild/linux-arm@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz#b9e9d070c8c1c0449cf12b20eac37d70a4595921" - integrity sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA== - -"@esbuild/linux-arm@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz#c09a0f67917592ac0de892a9be4d3814debd2a6c" - integrity sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ== - -"@esbuild/linux-ia32@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz#3f80fb696aa96051a94047f35c85b08b21c36f9e" - integrity sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg== - -"@esbuild/linux-ia32@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz#a580f9c676797833891e519fc7a1337c8afd8db3" - integrity sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w== - -"@esbuild/linux-loong64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz#9be1f2c28210b13ebb4156221bba356fe1675205" - integrity sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q== - -"@esbuild/linux-loong64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz#46452cf321dc7f9e91c2fa780a56bb56e79cd68b" - integrity sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg== - -"@esbuild/linux-mips64el@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz#4ab5ee67a3dfcbcb5e8fd7883dae6e735b1163b8" - integrity sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw== - -"@esbuild/linux-mips64el@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz#4211b3184dd6608f53dcb22e39f5d34ee08852c8" - integrity sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ== - -"@esbuild/linux-ppc64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz#dac78c689f6499459c4321e5c15032c12307e7ea" - integrity sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ== - -"@esbuild/linux-ppc64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz#697857c2a61cb9b0b6bb6652e40c1dc5e1ca8e5d" - integrity sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ== - -"@esbuild/linux-riscv64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz#050f7d3b355c3a98308e935bc4d6325da91b0027" - integrity sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ== - -"@esbuild/linux-riscv64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz#d192943eb146a40ac4c6497d0cf7be35b986bf08" - integrity sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ== - -"@esbuild/linux-s390x@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz#d61f715ce61d43fe5844ad0d8f463f88cbe4fef6" - integrity sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw== - -"@esbuild/linux-s390x@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz#acea0356da0e0ebc08f97cf7b9c2e401e1e648dc" - integrity sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag== - -"@esbuild/linux-x64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz#ca8e1aa478fc8209257bf3ac8f79c4dc2982f32a" - integrity sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA== - -"@esbuild/linux-x64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz#6f0c3ce0cb64c534b70c4c45ecb2c16d34e35dfd" - integrity sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA== - -"@esbuild/netbsd-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz#1650f2c1b948deeb3ef948f2fc30614723c09690" - integrity sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w== - -"@esbuild/netbsd-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz#8bcd77077a0dce3378b574fedb26d2a253b73d36" - integrity sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw== - -"@esbuild/netbsd-x64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz#65772ab342c4b3319bf0705a211050aac1b6e320" - integrity sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw== - -"@esbuild/netbsd-x64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz#e7fb2a01e99c830c94e6623cd9fefb4c8fb58347" - integrity sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg== - -"@esbuild/openbsd-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz#37ed7cfa66549d7955852fce37d0c3de4e715ea1" - integrity sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A== - -"@esbuild/openbsd-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz#c52909372db8b86e2c55e05a8940033b5660a3b2" - integrity sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q== - -"@esbuild/openbsd-x64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz#01bf3d385855ef50cb33db7c4b52f957c34cd179" - integrity sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg== - -"@esbuild/openbsd-x64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz#c427b9be5a64c262ff9a7eb70b5fbbaadf446c6c" - integrity sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw== - -"@esbuild/openharmony-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz#6c1f94b34086599aabda4eac8f638294b9877410" - integrity sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw== - -"@esbuild/openharmony-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz#dc9b147baca2e6c4b3c85571741ef4860a489097" - integrity sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg== - -"@esbuild/sunos-x64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz#4b0dd17ae0a6941d2d0fd35a906392517071a90d" - integrity sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA== - -"@esbuild/sunos-x64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz#ce866d12df13c15e4c99f073a3d466f6e0649b3a" - integrity sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ== - -"@esbuild/win32-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz#34193ab5565d6ff68ca928ac04be75102ccb2e77" - integrity sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA== - -"@esbuild/win32-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz#7468e3692d01d629d5941e5d83817bb80f9e39b4" - integrity sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA== - -"@esbuild/win32-ia32@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz#eb67f0e4482515d8c1894ede631c327a4da9fc4d" - integrity sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw== - -"@esbuild/win32-ia32@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz#a5bc0063fb2bcab6d0ed63f2a1537958bc269ec6" - integrity sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg== - -"@esbuild/win32-x64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz#8fe30b3088b89b4873c3a6cc87597ae3920c0a8b" - integrity sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg== - -"@esbuild/win32-x64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz#10064ee44f4347b90c9a02b446bbf80a91632b12" - integrity sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A== +"@esbuild/aix-ppc64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz#bf6e10303bcf2e7c686975fa52f937ec2728d8bc" + integrity sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ== + +"@esbuild/android-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz#0c6246bc8d2c4d172aac2db3fb1190d72bd65504" + integrity sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A== + +"@esbuild/android-arm@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz#2d84ece6a4e2684d92be26ee13d42757d831c381" + integrity sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg== + +"@esbuild/android-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz#fc38d4d6358d8dc1cf53f09f7589fe436eb64801" + integrity sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q== + +"@esbuild/darwin-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz#f83afeeac1d7dac01c7a2fd012b3e451a0591fcc" + integrity sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw== + +"@esbuild/darwin-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz#510147c055a795588dbbe14fd6b1b8ad0a2f30de" + integrity sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw== + +"@esbuild/freebsd-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz#093b9200ecf0b115ba4e5e248a7485c9c5f8bd5e" + integrity sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw== + +"@esbuild/freebsd-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz#0be22b6df925d213e841ea87123af5df80b0faf7" + integrity sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg== + +"@esbuild/linux-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz#1bdbc651cda9ba9995c53ed9c71ceaa65094762d" + integrity sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug== + +"@esbuild/linux-arm@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz#beb12ad72b84f72d28488cc1b8ee9f7eb141d753" + integrity sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w== + +"@esbuild/linux-ia32@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz#b81f9d55529b45c206a46a138214b1aa6879696b" + integrity sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ== + +"@esbuild/linux-loong64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz#598667241a04c99b76ed6ef940ac50038c419f98" + integrity sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ== + +"@esbuild/linux-mips64el@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz#1c51eb9cea903f53d97b5af3b1841db70f5596ca" + integrity sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA== + +"@esbuild/linux-ppc64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz#63dd61f17ceb31a81227f413feac8a71bc2c51f2" + integrity sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ== + +"@esbuild/linux-riscv64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz#3763b08fde5cf25ab1facb8e7752edfe45fbfc27" + integrity sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA== + +"@esbuild/linux-s390x@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz#1a137ff293a82906eb3176385bd7e8e0e5cfb7cb" + integrity sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg== + +"@esbuild/linux-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz#268b36211c146ca54f8fe12c578a8d6ef8979485" + integrity sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ== + +"@esbuild/netbsd-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz#22571ad951d62bb6accc82d8d1fad5c8c1ac0ba1" + integrity sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw== + +"@esbuild/netbsd-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz#42fcc57297eb0a0ca3f5fc475291f4c1a3f7c0de" + integrity sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw== + +"@esbuild/openbsd-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz#9eb32af104ac3dacf4edca01f596664aab0c73ef" + integrity sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ== + +"@esbuild/openbsd-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz#febed2402d6088225e91f20fb4ce2522ad0a4efd" + integrity sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw== + +"@esbuild/openharmony-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz#85641c3d466428bfbccea5f21c26836663fef5ce" + integrity sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q== + +"@esbuild/sunos-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz#a736f9d8962481045fc4c3e54f5479f22c870fb4" + integrity sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g== + +"@esbuild/win32-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz#ee5ab40fad186201b652a33f8a5eb149e9e42532" + integrity sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ== + +"@esbuild/win32-ia32@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz#c40d28a6d99a127da6711f2afd74b11cb63b06a7" + integrity sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA== + +"@esbuild/win32-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz#b21affb804cc167c133d95f45b3a1dc1323b9a87" + integrity sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g== "@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1": version "4.9.1" @@ -2074,6 +1988,13 @@ "@emnapi/runtime" "^1.4.3" "@tybys/wasm-util" "^0.10.0" +"@napi-rs/wasm-runtime@^1.1.4", "@napi-rs/wasm-runtime@^1.1.6": + version "1.2.2" + resolved "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz#c70706532e5827c0932ca6bf43ee2c512f29c639" + integrity sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw== + dependencies: + "@tybys/wasm-util" "^0.10.3" + "@next/env@16.2.11": version "16.2.11" resolved "https://registry.npmjs.org/@next/env/-/env-16.2.11.tgz#9dea1a225a99b1636e5a7166237db1f979b6c532" @@ -2270,6 +2191,214 @@ resolved "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz#0acf32f470af2ceaf47f095cdecd40d68666efda" integrity sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg== +"@oxc-parser/binding-android-arm-eabi@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.127.0.tgz#b75e796249ee22f632e40e942746c4bf648cee92" + integrity sha512-0LC7ye4hvqbIKxAzThzvswgHLFu2AURKzYLeSVvLdu2TBOYWQDmHnTqPLeA597BcUCxiLqLsS4CJ5uoI5WYWCQ== + +"@oxc-parser/binding-android-arm64@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.127.0.tgz#e264467fe39f80018f62fa0dae82db0b80260444" + integrity sha512-b5jtVTH6AU5CJXHNdj7Jj9IEiR9yVjjnwHzPJhGyHGPdcsZSzBCkS9GBbV33niRMvKthDwQRFRJfI4a+k4PvYg== + +"@oxc-parser/binding-darwin-arm64@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.127.0.tgz#0576d35109c00dcc6277200ba2eca7b47e07f1b1" + integrity sha512-obCE8B7ISKkJidjlhv9xRGJPOSDG2Yu6PRga9Ruaz35uintHxbp1Ki/Yc71wx4rj3Edrm0a1kzG1TAwit0wFpg== + +"@oxc-parser/binding-darwin-x64@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.127.0.tgz#efa1ba49075aa318ff540a1c2f8a442017417206" + integrity sha512-JL6Xb5IwPQT8rUzlpsX7E+AgfcdNklXNPFp8pjCQQ5MQOQo5rtEB2ui+3Hgg9Sn7Y9Egj6YOLLiHhLpdAe12Aw== + +"@oxc-parser/binding-freebsd-x64@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.127.0.tgz#817ba3c508d751d94d6e6fd86af69ddaa27da531" + integrity sha512-SDQ/3MQFw58fqQz3Z1PhSKFF3JoCF4gmlNjziDm8X02tTahCw0qJbd7FGPDKw1i4VTBZene9JPyC3mHtSvi+wA== + +"@oxc-parser/binding-linux-arm-gnueabihf@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.127.0.tgz#b1c3096c654771998480316ef10d1e5d29edc79b" + integrity sha512-Av+D1MIqzV0YMGPT9we2SIZaMKD7Cxs4CvXSx/yxaWHewZjYEjScpOf5igc8IILASViw4WTnjlwUdI1KzVtDHQ== + +"@oxc-parser/binding-linux-arm-musleabihf@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.127.0.tgz#c44a8f10e6c903685825aebf1289fc2086aed61e" + integrity sha512-Cs2fdJ8cPpFdeebj6p4dag8A4+56hPvZ0AhQQzlaLswGz1tz7bXt1nETLeorrM9+AMcWFFkqxcXwDGfTVidY8g== + +"@oxc-parser/binding-linux-arm64-gnu@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.127.0.tgz#61c245abfab6f63045915b5c9cfa7d335ad7c440" + integrity sha512-qdOfTcT6SY8gsJrrV92uyEUyjqMGPpIB5JZUG6QN5dukYd+7/j0kX6MwK1DgQj39jtUYixxPiaRUiEN1+0CXgQ== + +"@oxc-parser/binding-linux-arm64-musl@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.127.0.tgz#358bbd90e5c85b6c35125f5a6ff084e09b694c04" + integrity sha512-EoTCZneNFU/P2qrpEM+RHmQwt+CvDkyGESG6qhr7KaegXLZwePfbrkCDfAk8/rhxbDUVGsZILX+2tqPzFtoFWA== + +"@oxc-parser/binding-linux-ppc64-gnu@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.127.0.tgz#b7ea7b51bf54db4c42819187f760e069d433dac3" + integrity sha512-zALjmZYgxFLHjXeudcDF0xFGNydTAtkAeXAr2EuC17ywCyFxcmQra4w0BMde0Yi/re4Bi4iwEoEXtYN7l6eBLQ== + +"@oxc-parser/binding-linux-riscv64-gnu@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.127.0.tgz#3a3b10d160988df50bbbcd631c6af39de3dd451d" + integrity sha512-fPP8M6zQLS7Jz7o9d5ArUSuAuSK3e+WCYVrCpdzeCOejidtZExJ9tjhDrAd3HEPqARBCPmdpqxESPFqy44vkBQ== + +"@oxc-parser/binding-linux-riscv64-musl@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.127.0.tgz#3787d37e1d0a15ee239f51610298500321b31730" + integrity sha512-7IcC4Ao02oGpfnjt+X/oF4U2mllo2qoSkw5xxiXNKL9MCTsTiAC6616beOuehdxGcnz1bRoPC1RQ2f1GQDdN+g== + +"@oxc-parser/binding-linux-s390x-gnu@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.127.0.tgz#b71a16cbba115a4696498f9149bc54cc4e1df9cd" + integrity sha512-pbXIhiNFHoqWeqDNLiJ9JkpHz1IM9k4DXa66x+1GTWMG7iLxtkXgE53iiuKSXwmk3zIYmaPVfBvgcAhS583K4Q== + +"@oxc-parser/binding-linux-x64-gnu@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.127.0.tgz#71527dd0284ba727d35a93c841c91192af3ebdec" + integrity sha512-MYCguB9RvBvlSd6gbuNI7QwiLoCCAlGnlRJFPrzLI6U1/9wkC/WK6LtBAUln55H1Ctqw45PWmqrobKoMhsYQzQ== + +"@oxc-parser/binding-linux-x64-musl@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.127.0.tgz#16830afa4b001f349cebb93e12b278e72601cb3f" + integrity sha512-5eY0B/bxf1xIUxb4NOTvOI3KWtBQfPWYyKAzgcrCt0mDibSZygVpO1Pz8bkeiSZ5Jj9+M09dkggG3H8I5d0Uyg== + +"@oxc-parser/binding-openharmony-arm64@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.127.0.tgz#a41c71d249cb597dc357038eb1cbe3ce732453f8" + integrity sha512-Gld0ajrFTUXNtdw20fVBuTQx66FA75nIVg+//pPfR3sXkuABB4mTBhl3r9JNzrJpgW//qiwxf0nWXUWGJSL3UQ== + +"@oxc-parser/binding-wasm32-wasi@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.127.0.tgz#b1efcdb433b30ed4a3ad912fa03da3834bd4845d" + integrity sha512-T6KVD7rhLzFlwGRXMnxUFfkCZD8FHnb968wVXW1mXzgRFc5RNXOBY2mPPDZ77x5Ln76ltLMgtPg0cOkU1NSrEQ== + dependencies: + "@emnapi/core" "1.9.2" + "@emnapi/runtime" "1.9.2" + "@napi-rs/wasm-runtime" "^1.1.4" + +"@oxc-parser/binding-win32-arm64-msvc@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.127.0.tgz#b62b5e328126323d41ae1ee7adc95537c4c4423a" + integrity sha512-Ujvw4X+LD1CCGULcsQcvb4YNVoBGqt+JHgNNzGGaCImELiZLk477ifUH53gIbE7EKd933NdTi25JWEr9K2HwXw== + +"@oxc-parser/binding-win32-ia32-msvc@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.127.0.tgz#dac30de6971dbe63aa5722be9a4cc070fd3c650e" + integrity sha512-0cwxKO7KHQQQfo4Uf4B2SQrhgm+cJaP9OvFFhx52Tkg4bezsacu83GB2/In5bC415Ueeym+kXdnge/57rbSfTw== + +"@oxc-parser/binding-win32-x64-msvc@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.127.0.tgz#a2df879b0803f72b350a7567365cee5b8978edf0" + integrity sha512-rOrnSQSCbhI2kowr9XxE7m9a8oQXnBHjnS6j95LxxAnEZ0+Fz20WlRXG4ondQb+ejjt2KOsa65sE6++L6kUd+w== + +"@oxc-project/types@^0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz#8374fcdfb4a641861218daa5700c447c00b66663" + integrity sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ== + +"@oxc-resolver/binding-android-arm-eabi@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.24.2.tgz#5db3f0dcd659e1de664fb0ae912420839348309b" + integrity sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA== + +"@oxc-resolver/binding-android-arm64@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.24.2.tgz#fec00a8bc89afa9a164bad79b23c14b8c86f95cf" + integrity sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg== + +"@oxc-resolver/binding-darwin-arm64@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.24.2.tgz#b5e6e2c2bed585cbfd67b6e41eea7fce2a3da5f8" + integrity sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w== + +"@oxc-resolver/binding-darwin-x64@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.24.2.tgz#db759d6fadac262a7da21b1bfb712b0199c9cd18" + integrity sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q== + +"@oxc-resolver/binding-freebsd-x64@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.24.2.tgz#7fe0ab0725284aee9b6b6c45b81f0facf895fc62" + integrity sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw== + +"@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.24.2.tgz#91a72b7987930c3acc5337237454ba0339f80333" + integrity sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg== + +"@oxc-resolver/binding-linux-arm-musleabihf@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.24.2.tgz#72d04ad7d2227fb3ac71aa7933794bfb88194b7b" + integrity sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA== + +"@oxc-resolver/binding-linux-arm64-gnu@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.24.2.tgz#b87faf59bde9ecff0b8288fe99a831eb7492f99d" + integrity sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA== + +"@oxc-resolver/binding-linux-arm64-musl@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.24.2.tgz#2da6551c561bf2f2bed34c312a99e18d184a9f07" + integrity sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw== + +"@oxc-resolver/binding-linux-ppc64-gnu@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.24.2.tgz#fda4558cc94e43fefdfa4f9b96391c30ec137adb" + integrity sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA== + +"@oxc-resolver/binding-linux-riscv64-gnu@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.24.2.tgz#2fe243a5112d221021a8b2fccd7b36aa96adc946" + integrity sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw== + +"@oxc-resolver/binding-linux-riscv64-musl@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.24.2.tgz#e95cb43856f7e9c4aa0afa438e043fdbf6c6d40d" + integrity sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w== + +"@oxc-resolver/binding-linux-s390x-gnu@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.24.2.tgz#8e3ca765e1af7ccab8a61adc96323810f9770535" + integrity sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ== + +"@oxc-resolver/binding-linux-x64-gnu@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.24.2.tgz#a2b14c1efc3252e705038bc23b7225a7cb434df2" + integrity sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg== + +"@oxc-resolver/binding-linux-x64-musl@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.24.2.tgz#d42f14a6a286b0a81871e2be6ee2eeb3d044afce" + integrity sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw== + +"@oxc-resolver/binding-openharmony-arm64@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.24.2.tgz#1ce27bc037073b624c484e481ae61f4cc9b5cfbc" + integrity sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg== + +"@oxc-resolver/binding-wasm32-wasi@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.24.2.tgz#9c818fd9512eed502da1972de1f8c9528b4c9d27" + integrity sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q== + dependencies: + "@emnapi/core" "1.11.2" + "@emnapi/runtime" "1.11.2" + "@napi-rs/wasm-runtime" "^1.1.6" + +"@oxc-resolver/binding-win32-arm64-msvc@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.24.2.tgz#0e2bd6869ef554ffd3016594951f1f44f5f02617" + integrity sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg== + +"@oxc-resolver/binding-win32-x64-msvc@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.24.2.tgz#d0649344fcd504dfaf7f3561a53617c38d98d789" + integrity sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw== + "@polka/url@^1.0.0-next.24": version "1.0.0-next.29" resolved "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz#5a40109a1ab5f84d6fd8fc928b19f367cbe7e7b1" @@ -2698,7 +2827,7 @@ resolved "https://registry.npmjs.org/@storybook/global/-/global-5.0.0.tgz#b793d34b94f572c1d7d9e0f44fac4e0dbc9572ed" integrity sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ== -"@storybook/icons@^2.0.1": +"@storybook/icons@^2.0.1", "@storybook/icons@^2.0.2": version "2.1.0" resolved "https://registry.npmjs.org/@storybook/icons/-/icons-2.1.0.tgz#edfc2450a39c5e780f28c6cbc49acd7bff59b41a" integrity sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg== @@ -2954,7 +3083,7 @@ resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.11.2.tgz#00409e743ac4eea9afe5b7708594d5fcebb00212" integrity sha512-vTtpNt7mKCiZ1pwU9hfKPhpdVO2sVzFQsxoVBGtOSHxlrRRzYr8iQ2TlwbAcRYCcEiZ9ECAM8kBzH0v2+VzfKw== -"@testing-library/dom@10.4.1": +"@testing-library/dom@10.4.1", "@testing-library/dom@^10.4.1": version "10.4.1" resolved "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz#d444f8a889e9a46e9a3b4f3b88e0fcb3efb6cf95" integrity sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg== @@ -2980,18 +3109,6 @@ picocolors "^1.1.1" redent "^3.0.0" -"@testing-library/jest-dom@^6.9.1": - version "6.10.0" - resolved "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.10.0.tgz#8a76841e94b72d55d09d2a34b9db9d75da9cbc08" - integrity sha512-HQwu0KaB2zyT0iLzBL+8CLyZDL3KlZlZJ+2iyc9uCUnlJVskJU/UlPuVCyIPhtukjPQdT2QNoR5nCP5FqTmmDQ== - dependencies: - "@adobe/css-tools" "^4.4.0" - aria-query "^5.0.0" - css.escape "^1.5.1" - dom-accessibility-api "^0.6.3" - picocolors "^1.1.1" - redent "^3.0.0" - "@testing-library/react@16.3.2": version "16.3.2" resolved "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz#672883b7acb8e775fc0492d9e9d25e06e89786d0" @@ -3004,25 +3121,20 @@ resolved "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz#13e09a32d7a8b7060fe38304788ebf4197cd2149" integrity sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw== -"@tiptap/core@^3.20.5": - version "3.27.3" - resolved "https://registry.npmjs.org/@tiptap/core/-/core-3.27.3.tgz#001d05642579b8c4727fe9acd71ce5f4900a508c" - integrity sha512-TJj5929M96C1KlH796wS8MywfHDh49RhmakOyzyMMc9pFmRj9UXi1gj0TCXgsZtjEOG7B+m/DRvNOvnuvR9kmg== - "@tiptap/core@^3.29.2": version "3.29.2" resolved "https://registry.npmjs.org/@tiptap/core/-/core-3.29.2.tgz#90d24591a9e7fb450ffb95ed6a42f529348e3e9e" integrity sha512-oKUkiPUB7noilVYxI9lNzUD4rX17sHub+PYjMfHMWHG9A3nvIy+FdePIVIIhThKWF7ijhr3eIqHY51Bn+GAFtw== -"@tiptap/extension-blockquote@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-blockquote/-/extension-blockquote-3.20.5.tgz#c64341fce14154b8c2785ead168d395436f953e7" - integrity sha512-0wU6H/MWWes0rGzgSW6MMU6YDs/3ofUDkqmqCqmb+Siu1ZD0bpzOYpBtujgOYDY8moB9+zCE3G9HSYGcmZxHew== +"@tiptap/extension-blockquote@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.29.2.tgz#e654719cee5b039a5b4af97226e2652f592d4196" + integrity sha512-ca4OzKDh0yaxg2+Z56bC2QnWsNsFp2YMRfVig1PDXyMVFMNJpLcnhxgq/9btn+xYAlYrj8RymOeCTYREOR6Zjg== -"@tiptap/extension-bold@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-bold/-/extension-bold-3.20.5.tgz#b40e8e43db3123c5dee9864931f7f9ad1b1e07dc" - integrity sha512-hraiiWkF58n8Jy0Wl3OGwjCTrGWwZZxez/IlexrzKQ/nMFdjDpensZucWwu59zhAM9fqZwGSLDtCFuak03WKnA== +"@tiptap/extension-bold@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.29.2.tgz#cd03d51096caa9135ccbb31c2bae778b5fac50df" + integrity sha512-elYbGxJsYnBb4leqrcjdIJuiG380BcOgN+UUzvOv+qEjfGVzHodFOMBl3qnmD6urYHNu5/qQK2S0qSSRXKCLNQ== "@tiptap/extension-bubble-menu@^3.20.5": version "3.22.3" @@ -3031,119 +3143,119 @@ dependencies: "@floating-ui/dom" "^1.0.0" -"@tiptap/extension-bullet-list@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-bullet-list/-/extension-bullet-list-3.20.5.tgz#dc53ab798a48c3aaf175752899f04cad2abc8ef3" - integrity sha512-MT3321R6F8AoVUEMJ5RiI0PQMenwvtmrSXoO1ehPCWq5TrSJLyXeZMJvZU+1CgfXk4XQU70RN78ib5+Zg+/FCg== +"@tiptap/extension-bullet-list@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.29.2.tgz#c25a6023c7ee13e35f76ffcc4fd436415e9fa0b7" + integrity sha512-3bWcCUPbCHv0XttlMdnAtXLNYWx2pblByMgxmGsaP9FU0QnslGXty6A6gHCqI33ygRg1vrA6U5Wtpwbi5aKu5g== -"@tiptap/extension-code-block@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-code-block/-/extension-code-block-3.20.5.tgz#96daefd431f37a87eac33095d020937dd438fe6c" - integrity sha512-0YZnqfqZ1IjzKBM4aezw8j3LZWJFEfs4+mbizHNlnZSYpKzpESYLeaLWGO5SpqF9Z8tmYmSoCaf0fqi5LwgdIA== +"@tiptap/extension-code-block@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.29.2.tgz#fd91f2475d0c9e289ac98cc3210f86d869af4e16" + integrity sha512-w153ct8g6dLiPTdXQ6SOIMxX4SEo5Q50AmjdEEEcJ7ZcYUcde/ScSskLHfOYmyt5ZFAiyEwr121+pux+p3/oAQ== -"@tiptap/extension-code@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-3.20.5.tgz#c6c93fcb553ddb9e185316a4876f79b7d5d21171" - integrity sha512-jBZK/CfdMvg1gkNK/zNAk02IExpBPwUfNLRPiJvGhReL2Q73naKxZGQGp+5Lej9VaeFB70UKuRma/iIzuZbgsA== +"@tiptap/extension-code@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.29.2.tgz#89a1398ae5e0bebcd7833c0e73621f11ccaa4a44" + integrity sha512-c6W5UGuB7WNLpYocsgRzpO2OOTI4QjaI9jjHRMuty9z+s9DtaYM/HrRLNwVh6MopkHb+i/89Wkv8gCS34fftig== -"@tiptap/extension-document@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-3.20.5.tgz#24a15654057872db469da6b91584875dcda070ea" - integrity sha512-BpNGHtOTAjjs/6QbkrafMTlaJqb0gsPngFzd5rB0csxx7rYRE9nIEY+oZ44qMw161+2YB4u20L17SX2mUJANBw== +"@tiptap/extension-document@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.29.2.tgz#215d692d4b5b9d7bbc8db8f9b9d4221f2a57c9a1" + integrity sha512-YUamvefLnsqu6124GavVTI7nqcFlQJ12ROB0oSwG69eSBZYNjg1tIs05LFrBxkwf4Xgqd6YzfJ9+FeG428RvzQ== -"@tiptap/extension-dropcursor@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-dropcursor/-/extension-dropcursor-3.20.5.tgz#ea810297825b009c357559e66f5fd76e91e8c940" - integrity sha512-/lDG9OjvAv0ynmgFH17mt/GUeGT5bqu0iPW8JMgaRqlKawk+uUIv5SF5WkXS4SwxXih+hXdPEQD3PWZnxlQxAQ== +"@tiptap/extension-dropcursor@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.29.2.tgz#faa11b6d0d9312e964fcf0087fdc985ca57bc344" + integrity sha512-KKno7cU9r1HdR48CRrsDu69/1UjZdoslq/UcE+Kx+tdhAv/aljXMkRSNzGMrBNOBDmHRgS1+58zm21WQWdQzwA== "@tiptap/extension-floating-menu@^3.20.5": version "3.22.3" resolved "https://registry.yarnpkg.com/@tiptap/extension-floating-menu/-/extension-floating-menu-3.22.3.tgz#c9a911b7784cb45d6f8e7260d77bf2015066e5a4" integrity sha512-0f8b4KZ3XKai8GXWseIYJGdOfQr3evtFbBo3U08zy2aYzMMXWG0zEF7qe5/oiYp2aZ95edjjITnEceviTsZkIg== -"@tiptap/extension-gapcursor@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-3.20.5.tgz#0fe2ffb1d7669fc4f5541a0c66342da4107b08f8" - integrity sha512-H+bRr+mqU/DQq1vfoMlppK1o+RbfSKYBMIcAMHWOez+C96MWfj5bhooVU2HLtl4XGmQxKGr3oEOCKDPdtRNThg== +"@tiptap/extension-gapcursor@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.29.2.tgz#c21fd638d9e4923f0260a7a5cfd4a38cf7c05216" + integrity sha512-8Q39UR4/Tit759IeW9xZIe3NMwN11GsuA3FLheDyyGn7RrW02HD3HhUDlazE54Ki4HoosjFmChPlN4Ik2ubdRQ== -"@tiptap/extension-hard-break@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-3.20.5.tgz#79a4409e81a35c9f8b664616a9b2ecbd4cb81953" - integrity sha512-+aILNDO7BsXf0IJ4/0BYh570usFK3Q1t/ZQd8zhHuO2ATeWeDVu1x2F+ouFS4X8fmoCcioMzw15aoz93GET6kQ== +"@tiptap/extension-hard-break@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.29.2.tgz#334ebaa9c0d2eed7df9037524eb0f6b15732fb1d" + integrity sha512-eUW3LN3fq8rXnjEUeI3D2QONYdLsU3yYQm4jxlErs2h4cfwrFjgf19VSUFVmm6LrFbbQ0OnDVPeVLL6iOwDw2w== -"@tiptap/extension-heading@^3.20.5", "@tiptap/extension-heading@^3.27.3": - version "3.27.3" - resolved "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.27.3.tgz#8f3b0f0f0afd172b6879fad265d43761bc8caa4b" - integrity sha512-QHXnsNic6iId8pnsFZ8z4PkX5L+HCHa/D7rAi3nNWtPlSIAOxo4nKrALcB5/tHmY+XL8kEXKH3nsNLNEDLCYPg== +"@tiptap/extension-heading@^3.27.3", "@tiptap/extension-heading@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.29.2.tgz#a7b392d7dd2cd463d9eda7556aaf0bb297795ad9" + integrity sha512-6W4aIy70Mh7BNlbG9zZ5FBLhJhU2UUEzgZJ/jwYSCcB30o8McLxJSEjhtoHiX8R78Ah2/JzBGvIe5olZlbeE4A== -"@tiptap/extension-horizontal-rule@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.20.5.tgz#c21b2c7405f4aad7b507e36cc3394aba51ea2253" - integrity sha512-4UtpUHg8cRzxWjJUGtni5VnXYbhsO7ygf1H1pr4Rv63XMBg9lfYDeSwByIuVy9biEFP7eGEFnezzb5Zlh1btmQ== +"@tiptap/extension-horizontal-rule@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.29.2.tgz#a2afe200c29f9355fa1a79c96fc73bef016bbbd2" + integrity sha512-8/ZPzbB9X85Mc9/7xVLZupQKBr2UVcQTGr512xtqMW+XkCQRHCph46tRo828YE13IMWI5fWn/FaNCqXG9cULSw== -"@tiptap/extension-italic@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-3.20.5.tgz#c53436f05968b16eda6b8e0efbaebaf3f4587e3b" - integrity sha512-7bZCgdJVTvhR5vSmNgFQbGvgRoC6m26KcUpHqWiKA95kLL5Wk4YlMCIqdiDpvJ1eakeFEvDcGZvFLg5+1NiQ+w== +"@tiptap/extension-italic@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.29.2.tgz#3d169fdcc34a603304f14946c6f638ddaadc9af8" + integrity sha512-iH63V/5wsaMnY4Jz0+meaAGhaec4AiOzOduzl6ZZr5IyGhZ1kthyW84ELt0dyLI3hNceAUhaNWc+I7+vX0aoXA== -"@tiptap/extension-link@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-link/-/extension-link-3.20.5.tgz#fbed2a1b82b0e9a73a2628782408135fbe698575" - integrity sha512-0PukrSYnHX2CrGSThlKfQWxpPWmL7QAvdpDUraKknGvVNSH7tUjchTshy5JdLrn/SQAU92REowRCB6zzCNEFjA== +"@tiptap/extension-link@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.29.2.tgz#a91ff8625cc609e9ba0bfb5d5052b4168436c4f5" + integrity sha512-DcVer5SqrexKCEP6Ip1UPxJUMvcRCCItSv0wxoGytanrimBh2smvcg6X0DWnjlsi5H0updhyl+atYCmmQXUIXA== dependencies: - linkifyjs "^4.3.2" + linkifyjs "^4.3.3" -"@tiptap/extension-list-item@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-list-item/-/extension-list-item-3.20.5.tgz#3bbe5c8cc2a5f6ad7900803338b41a29e33409ba" - integrity sha512-pFJCGLIDEin1Xn6B3ctbrZvtYyALARE56ya4SmaNfnl+Hww5MfkRR40obbwYD3byA1yOpr+bECy+I2clQqzTDw== +"@tiptap/extension-list-item@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.29.2.tgz#febddb22badf0facebf3325b915f65f4a7c8f697" + integrity sha512-s8vBVHHFT0Qpu7CzAZ7S1kYmSiVaDvUNvMNcZUWnxj6VPfiwmx0eXd9FsjePrRCMoM5tFmPnhFTHDxY3D/eZeQ== -"@tiptap/extension-list-keymap@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-list-keymap/-/extension-list-keymap-3.20.5.tgz#272077f1e1f55b4306583fcfa81d0208f8814a71" - integrity sha512-rmrQgOrUb0jKtFzVUfT0UNEST2sGM2Ve4lOl+1luh66RW6TD+gvgMk/qo12/Kffl9PUiqz8oYfk2qXCwFb6Bug== +"@tiptap/extension-list-keymap@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.29.2.tgz#cdb29a4ce7fdff9f986df713501272d9fc643930" + integrity sha512-R+3k8OLnxdCH7Xy9ieOwUt5m2Je74u8mikothGmsYVO2Zyq48fIbmZ+X6RBPCu7DBOI2FIUhHEFbKQeDWvDNmA== -"@tiptap/extension-list@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-list/-/extension-list-3.20.5.tgz#98acebb38d051790e97ebabcb93327ac8ecd6909" - integrity sha512-s+Y8Q7Orq+WQiwgFB/VPMYZe+6EAR2F69xCpvOynlzTInLO4cF6QpXomuGEYAZxLHe8ZBmeIaR7y8MH/OgjrDw== +"@tiptap/extension-list@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.29.2.tgz#53324e55f1c89efcf450b0c92f84361ced15e2ab" + integrity sha512-WPZ9BHAPT6QeIm1vdVkuoOWvy9a8/EZeJwV2VhU8LXyTAttvzyj4rsbbHyJWvYWlUSTt/QF2AZ2zhKo7u1w3/A== -"@tiptap/extension-ordered-list@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-ordered-list/-/extension-ordered-list-3.20.5.tgz#c5b5abff89ec2b0bd82a8c62828dc317832a0e66" - integrity sha512-Y/RIE3AxUNYAFKGMM5FLlTVKxxBvOh4JlLp/qYsOCY2nJdH0Jopl2FpfBYc4xoJwFSk8BELJ4Ow0adcYb15ksg== +"@tiptap/extension-ordered-list@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.29.2.tgz#2053ddaef243e455bb1267fbe81c337487fa4771" + integrity sha512-ndCunC+UsYOpkOtL7vGnDz21UNa45WUlcO9wMT1fbuYow2QnRhsuMlCWENXI52YPPARWuQ0RDgN7q6TaxPERBg== -"@tiptap/extension-paragraph@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-3.20.5.tgz#4344d623213bbec5a025b8c5cb751979a1f3b293" - integrity sha512-mwuhwmff67IpGfOViyRvUC14IlkpsOnB+hSExVnq5+hCntjt/Cr2Z8GGOgzHeIM2FIS0UqX9Lv/b6ttUg4+Now== +"@tiptap/extension-paragraph@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.29.2.tgz#da8de494530843891c399d18ab15ea2b2f5e2ffb" + integrity sha512-7qJj5YTr11vvjNgjDN1ypOfwTovc0QOCYcit/rskeuVgnmQZOZQzC/BbyKLLG7UGnpRLemU/mEGbW9pAqjAXkQ== -"@tiptap/extension-strike@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-3.20.5.tgz#a3689fc17ad89a23c88f11b27c7f53896caa54f3" - integrity sha512-uwhvmfS4ciGYJRLUg0AHbWsprMCwyWVWd2RXOLRm0ZQeWkvzonPXZhJvzIhIgsFkPLj/dsN5t0+LdiK4UQMnyA== +"@tiptap/extension-strike@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.29.2.tgz#fe17b6140955e8f45c7ad32c66539891be934441" + integrity sha512-aEvLAbddUQZ+FukCreV3q4G2HfNI+odE7E9U+wbq6XsSWKyo8/pDu1muz+TFKNre4blSMOQ3JQmw5UeHDKy+fg== "@tiptap/extension-table@^3.20.5": version "3.20.5" resolved "https://registry.yarnpkg.com/@tiptap/extension-table/-/extension-table-3.20.5.tgz#bac3d76e1c5fc8a4672f1495532a934651f50ce8" integrity sha512-YvTB5OfGqjqHqutkSyywplouFvJwlsDTpZAjtAh5TzKfOan42aiVepmHVpteoQP6LH0mSjw69RndFMIYhIGmSQ== -"@tiptap/extension-text@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-3.20.5.tgz#48e1cb2ee149eef7857b6a3131a32c341f572f05" - integrity sha512-DMa9g5cH2d/Gx1KXtV7txTxaa6FBqgG8glmfug+N93VMb8sEZR1Yu1az++yAep4SGGq9GWIGZCUS3H6W66et6Q== +"@tiptap/extension-text@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.29.2.tgz#7f590043b9044bd7cbc478c65e211bc3f2538335" + integrity sha512-Ubko45JWWHe8glBt2PiGNF8hcbys/JNalFhiR7Y1X4iOOtAxAKJJxh3+eq+//NTlGuBPdWGp7zw8EEUp7anjKA== -"@tiptap/extension-underline@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-underline/-/extension-underline-3.20.5.tgz#97321f4405b303f9d54d2716dec6ab5bf9bc493e" - integrity sha512-HMhr5KIAqZsEhlN8RxKHr/ql1a8OvBa9fLf69IwUVFolBcDExHWUtaEV/axYVRQJvvIy2oKGJxlJWDZ4hkotHQ== +"@tiptap/extension-underline@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.29.2.tgz#e3545cc020608fcb7bdde5f1136d3ee8e330cb4e" + integrity sha512-K7XwH/xS/5AIREWQ00VTEf/W5U0olp7j6wwit7cdd/8nHv6h6AGr1+iEApHKoLXWQZLfGQKzJlT9W61LAl+fHA== -"@tiptap/extensions@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extensions/-/extensions-3.20.5.tgz#d2460b110deed4a71aca4c0d37816fc8845b22ad" - integrity sha512-c4am6SznqfMnbUNSh4MvufiD7cMLdqL1BArok22uBgSWkS1sB9RVBYe8+x0jrOkk0UPEVlzDHbQ+nU+WmIyS2Q== +"@tiptap/extensions@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.29.2.tgz#207c6ed79db1a5baec13f3fd8653b437fca2c8b6" + integrity sha512-BCz+FCAChSYtUe4BFj97HEO+nSK+J7GxbJgZG4Hg7DT/gI+hRyeNndU8efiQAx3WGdzsFi3UxRpcF1tTQM7iMQ== -"@tiptap/pm@^3.20.5", "@tiptap/pm@^3.29.2": +"@tiptap/pm@^3.29.2": version "3.29.2" resolved "https://registry.npmjs.org/@tiptap/pm/-/pm-3.29.2.tgz#de461c6f8986ef807082f467cde2d8fdf1cce4bc" integrity sha512-GCOme7xHaS+DSoaA4CDcAD3l6JyBlvZhvCyfsy2Vp6j8tEoBkZWio7soYVosmlyn7zq8/64VeFZP5s47yfG7fQ== @@ -3174,35 +3286,35 @@ "@tiptap/extension-bubble-menu" "^3.20.5" "@tiptap/extension-floating-menu" "^3.20.5" -"@tiptap/starter-kit@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/starter-kit/-/starter-kit-3.20.5.tgz#67a6c7ed20b81f5746fc0552f4efc02bc6fbf684" - integrity sha512-L5E2TCGK0EiwmGIlwMsiwNTW1TLbfPF1Dsji4bSKRJnPbccZIMCB6qdId8v/Z+QGm85NVcBHeruQrDlKDddXBA== - dependencies: - "@tiptap/core" "^3.20.5" - "@tiptap/extension-blockquote" "^3.20.5" - "@tiptap/extension-bold" "^3.20.5" - "@tiptap/extension-bullet-list" "^3.20.5" - "@tiptap/extension-code" "^3.20.5" - "@tiptap/extension-code-block" "^3.20.5" - "@tiptap/extension-document" "^3.20.5" - "@tiptap/extension-dropcursor" "^3.20.5" - "@tiptap/extension-gapcursor" "^3.20.5" - "@tiptap/extension-hard-break" "^3.20.5" - "@tiptap/extension-heading" "^3.20.5" - "@tiptap/extension-horizontal-rule" "^3.20.5" - "@tiptap/extension-italic" "^3.20.5" - "@tiptap/extension-link" "^3.20.5" - "@tiptap/extension-list" "^3.20.5" - "@tiptap/extension-list-item" "^3.20.5" - "@tiptap/extension-list-keymap" "^3.20.5" - "@tiptap/extension-ordered-list" "^3.20.5" - "@tiptap/extension-paragraph" "^3.20.5" - "@tiptap/extension-strike" "^3.20.5" - "@tiptap/extension-text" "^3.20.5" - "@tiptap/extension-underline" "^3.20.5" - "@tiptap/extensions" "^3.20.5" - "@tiptap/pm" "^3.20.5" +"@tiptap/starter-kit@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.29.2.tgz#9e61bdfc628923e9c1cd5a6ed80a1d884ae1855c" + integrity sha512-oTu0tysiqk4zgjEtxRHjAQgxUKaAevZwueOWwSWubHdokqp7SpcbE5n9USJv89HKuTUDm3GjnQH6q8HNn/2DsA== + dependencies: + "@tiptap/core" "^3.29.2" + "@tiptap/extension-blockquote" "^3.29.2" + "@tiptap/extension-bold" "^3.29.2" + "@tiptap/extension-bullet-list" "^3.29.2" + "@tiptap/extension-code" "^3.29.2" + "@tiptap/extension-code-block" "^3.29.2" + "@tiptap/extension-document" "^3.29.2" + "@tiptap/extension-dropcursor" "^3.29.2" + "@tiptap/extension-gapcursor" "^3.29.2" + "@tiptap/extension-hard-break" "^3.29.2" + "@tiptap/extension-heading" "^3.29.2" + "@tiptap/extension-horizontal-rule" "^3.29.2" + "@tiptap/extension-italic" "^3.29.2" + "@tiptap/extension-link" "^3.29.2" + "@tiptap/extension-list" "^3.29.2" + "@tiptap/extension-list-item" "^3.29.2" + "@tiptap/extension-list-keymap" "^3.29.2" + "@tiptap/extension-ordered-list" "^3.29.2" + "@tiptap/extension-paragraph" "^3.29.2" + "@tiptap/extension-strike" "^3.29.2" + "@tiptap/extension-text" "^3.29.2" + "@tiptap/extension-underline" "^3.29.2" + "@tiptap/extensions" "^3.29.2" + "@tiptap/pm" "^3.29.2" "@tybys/wasm-util@^0.10.0": version "0.10.1" @@ -3211,6 +3323,13 @@ dependencies: tslib "^2.4.0" +"@tybys/wasm-util@^0.10.3": + version "0.10.3" + resolved "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz#015cba9e9dd47ce14d03d2a8c5d547bfb169665d" + integrity sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg== + dependencies: + tslib "^2.4.0" + "@types/aria-query@^5.0.1": version "5.0.4" resolved "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz#1a31c3d378850d2778dabb6374d036dcba4ba708" @@ -4051,9 +4170,9 @@ asynckit@^0.4.0: resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== -attr-accept@^2.2.4: +attr-accept@^2.2.5: version "2.2.5" - resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.2.5.tgz#d7061d958e6d4f97bf8665c68b75851a0713ab5e" + resolved "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz#d7061d958e6d4f97bf8665c68b75851a0713ab5e" integrity sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ== available-typed-arrays@^1.0.7: @@ -5189,69 +5308,37 @@ es-toolkit@^1.39.3: resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.45.1.tgz#21b28b2bd43178fd4c9c937c445d5bcaccce907b" integrity sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw== -"esbuild@^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0": - version "0.27.7" - resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz#bcadce22b2f3fd76f257e3a64f83a64986fea11f" - integrity sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w== +"esbuild@^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0", "esbuild@^0.27.0 || ^0.28.0": + version "0.28.2" + resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz#0f43bd1bad955b72d24e2261e3abe5957ccf0816" + integrity sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA== optionalDependencies: - "@esbuild/aix-ppc64" "0.27.7" - "@esbuild/android-arm" "0.27.7" - "@esbuild/android-arm64" "0.27.7" - "@esbuild/android-x64" "0.27.7" - "@esbuild/darwin-arm64" "0.27.7" - "@esbuild/darwin-x64" "0.27.7" - "@esbuild/freebsd-arm64" "0.27.7" - "@esbuild/freebsd-x64" "0.27.7" - "@esbuild/linux-arm" "0.27.7" - "@esbuild/linux-arm64" "0.27.7" - "@esbuild/linux-ia32" "0.27.7" - "@esbuild/linux-loong64" "0.27.7" - "@esbuild/linux-mips64el" "0.27.7" - "@esbuild/linux-ppc64" "0.27.7" - "@esbuild/linux-riscv64" "0.27.7" - "@esbuild/linux-s390x" "0.27.7" - "@esbuild/linux-x64" "0.27.7" - "@esbuild/netbsd-arm64" "0.27.7" - "@esbuild/netbsd-x64" "0.27.7" - "@esbuild/openbsd-arm64" "0.27.7" - "@esbuild/openbsd-x64" "0.27.7" - "@esbuild/openharmony-arm64" "0.27.7" - "@esbuild/sunos-x64" "0.27.7" - "@esbuild/win32-arm64" "0.27.7" - "@esbuild/win32-ia32" "0.27.7" - "@esbuild/win32-x64" "0.27.7" - -"esbuild@^0.27.0 || ^0.28.0": - version "0.28.1" - resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz#ef45b4634c9c9d97a296aea4114a5f9840f95578" - integrity sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw== - optionalDependencies: - "@esbuild/aix-ppc64" "0.28.1" - "@esbuild/android-arm" "0.28.1" - "@esbuild/android-arm64" "0.28.1" - "@esbuild/android-x64" "0.28.1" - "@esbuild/darwin-arm64" "0.28.1" - "@esbuild/darwin-x64" "0.28.1" - "@esbuild/freebsd-arm64" "0.28.1" - "@esbuild/freebsd-x64" "0.28.1" - "@esbuild/linux-arm" "0.28.1" - "@esbuild/linux-arm64" "0.28.1" - "@esbuild/linux-ia32" "0.28.1" - "@esbuild/linux-loong64" "0.28.1" - "@esbuild/linux-mips64el" "0.28.1" - "@esbuild/linux-ppc64" "0.28.1" - "@esbuild/linux-riscv64" "0.28.1" - "@esbuild/linux-s390x" "0.28.1" - "@esbuild/linux-x64" "0.28.1" - "@esbuild/netbsd-arm64" "0.28.1" - "@esbuild/netbsd-x64" "0.28.1" - "@esbuild/openbsd-arm64" "0.28.1" - "@esbuild/openbsd-x64" "0.28.1" - "@esbuild/openharmony-arm64" "0.28.1" - "@esbuild/sunos-x64" "0.28.1" - "@esbuild/win32-arm64" "0.28.1" - "@esbuild/win32-ia32" "0.28.1" - "@esbuild/win32-x64" "0.28.1" + "@esbuild/aix-ppc64" "0.28.2" + "@esbuild/android-arm" "0.28.2" + "@esbuild/android-arm64" "0.28.2" + "@esbuild/android-x64" "0.28.2" + "@esbuild/darwin-arm64" "0.28.2" + "@esbuild/darwin-x64" "0.28.2" + "@esbuild/freebsd-arm64" "0.28.2" + "@esbuild/freebsd-x64" "0.28.2" + "@esbuild/linux-arm" "0.28.2" + "@esbuild/linux-arm64" "0.28.2" + "@esbuild/linux-ia32" "0.28.2" + "@esbuild/linux-loong64" "0.28.2" + "@esbuild/linux-mips64el" "0.28.2" + "@esbuild/linux-ppc64" "0.28.2" + "@esbuild/linux-riscv64" "0.28.2" + "@esbuild/linux-s390x" "0.28.2" + "@esbuild/linux-x64" "0.28.2" + "@esbuild/netbsd-arm64" "0.28.2" + "@esbuild/netbsd-x64" "0.28.2" + "@esbuild/openbsd-arm64" "0.28.2" + "@esbuild/openbsd-x64" "0.28.2" + "@esbuild/openharmony-arm64" "0.28.2" + "@esbuild/sunos-x64" "0.28.2" + "@esbuild/win32-arm64" "0.28.2" + "@esbuild/win32-ia32" "0.28.2" + "@esbuild/win32-x64" "0.28.2" escalade@^3.1.1, escalade@^3.2.0: version "3.2.0" @@ -5634,12 +5721,10 @@ file-entry-cache@^8.0.0: dependencies: flat-cache "^4.0.0" -file-selector@^2.1.0: - version "2.1.2" - resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-2.1.2.tgz#fe7c7ee9e550952dfbc863d73b14dc740d7de8b4" - integrity sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig== - dependencies: - tslib "^2.7.0" +file-selector@^4.1.0: + version "4.1.0" + resolved "https://registry.npmjs.org/file-selector/-/file-selector-4.1.0.tgz#8759e5b0ef030c5cee36ea6f4b66cd9b23a40d86" + integrity sha512-Io1mP8CI3zec5Bxy3P3TxdrKnt35Cm8vNIHnZsvyj43l4YFjD4NRInBp240S5bDJQ0EP1jnh7nCAwXsO818OCg== fill-range@^7.1.1: version "7.1.1" @@ -6650,6 +6735,11 @@ json5@^2.2.2, json5@^2.2.3: resolved "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== +jsonc-parser@^3.3.1: + version "3.3.1" + resolved "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz#f2a524b4f7fd11e3d791e559977ad60b98b798b4" + integrity sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ== + jspdf-autotable@^5.0.8: version "5.0.8" resolved "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-5.0.8.tgz#b010dab34caf5eff60bbcd09a36d0608dc0a84ae" @@ -6734,10 +6824,10 @@ lines-and-columns@^1.1.6: resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== -linkifyjs@^4.3.2: - version "4.3.2" - resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-4.3.2.tgz#d97eb45419aabf97ceb4b05a7adeb7b8c8ade2b1" - integrity sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA== +linkifyjs@^4.3.3: + version "4.3.3" + resolved "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz#da08f0eeb4d89a24541d09591fbdcc211eb8fef0" + integrity sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg== locate-path@^6.0.0: version "6.0.0" @@ -7694,6 +7784,59 @@ own-keys@^1.0.1: object-keys "^1.1.1" safe-push-apply "^1.0.0" +oxc-parser@^0.127.0: + version "0.127.0" + resolved "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.127.0.tgz#bb14600f5c59fb6b1fbac0ab6ff2cd3495a6df1d" + integrity sha512-bkgD4qHlN7WxLdX8bLXdaU54TtQtAIg/ZBAfm0aje/mo3MRDo3P0hZSgr4U7O3xfX+fQmR5AP04JS/TGcZLcFA== + dependencies: + "@oxc-project/types" "^0.127.0" + optionalDependencies: + "@oxc-parser/binding-android-arm-eabi" "0.127.0" + "@oxc-parser/binding-android-arm64" "0.127.0" + "@oxc-parser/binding-darwin-arm64" "0.127.0" + "@oxc-parser/binding-darwin-x64" "0.127.0" + "@oxc-parser/binding-freebsd-x64" "0.127.0" + "@oxc-parser/binding-linux-arm-gnueabihf" "0.127.0" + "@oxc-parser/binding-linux-arm-musleabihf" "0.127.0" + "@oxc-parser/binding-linux-arm64-gnu" "0.127.0" + "@oxc-parser/binding-linux-arm64-musl" "0.127.0" + "@oxc-parser/binding-linux-ppc64-gnu" "0.127.0" + "@oxc-parser/binding-linux-riscv64-gnu" "0.127.0" + "@oxc-parser/binding-linux-riscv64-musl" "0.127.0" + "@oxc-parser/binding-linux-s390x-gnu" "0.127.0" + "@oxc-parser/binding-linux-x64-gnu" "0.127.0" + "@oxc-parser/binding-linux-x64-musl" "0.127.0" + "@oxc-parser/binding-openharmony-arm64" "0.127.0" + "@oxc-parser/binding-wasm32-wasi" "0.127.0" + "@oxc-parser/binding-win32-arm64-msvc" "0.127.0" + "@oxc-parser/binding-win32-ia32-msvc" "0.127.0" + "@oxc-parser/binding-win32-x64-msvc" "0.127.0" + +oxc-resolver@^11.19.1: + version "11.24.2" + resolved "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.24.2.tgz#85c08d9f5797e600175fa8524d2d271c685d97cf" + integrity sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw== + optionalDependencies: + "@oxc-resolver/binding-android-arm-eabi" "11.24.2" + "@oxc-resolver/binding-android-arm64" "11.24.2" + "@oxc-resolver/binding-darwin-arm64" "11.24.2" + "@oxc-resolver/binding-darwin-x64" "11.24.2" + "@oxc-resolver/binding-freebsd-x64" "11.24.2" + "@oxc-resolver/binding-linux-arm-gnueabihf" "11.24.2" + "@oxc-resolver/binding-linux-arm-musleabihf" "11.24.2" + "@oxc-resolver/binding-linux-arm64-gnu" "11.24.2" + "@oxc-resolver/binding-linux-arm64-musl" "11.24.2" + "@oxc-resolver/binding-linux-ppc64-gnu" "11.24.2" + "@oxc-resolver/binding-linux-riscv64-gnu" "11.24.2" + "@oxc-resolver/binding-linux-riscv64-musl" "11.24.2" + "@oxc-resolver/binding-linux-s390x-gnu" "11.24.2" + "@oxc-resolver/binding-linux-x64-gnu" "11.24.2" + "@oxc-resolver/binding-linux-x64-musl" "11.24.2" + "@oxc-resolver/binding-openharmony-arm64" "11.24.2" + "@oxc-resolver/binding-wasm32-wasi" "11.24.2" + "@oxc-resolver/binding-win32-arm64-msvc" "11.24.2" + "@oxc-resolver/binding-win32-x64-msvc" "11.24.2" + p-limit@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" @@ -8173,14 +8316,13 @@ react-dom@19.2.8, "react-dom@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0": dependencies: scheduler "^0.27.0" -react-dropzone@15.0.0: - version "15.0.0" - resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-15.0.0.tgz#bd03c7c2b14fe4ea9db1a9c74502b85339f2e505" - integrity sha512-lGjYV/EoqEjEWPnmiSvH4v5IoIAwQM2W4Z1C0Q/Pw2xD0eVzKPS359BQTUMum+1fa0kH2nrKjuavmTPOGhpLPg== +react-dropzone@20.0.0: + version "20.0.0" + resolved "https://registry.npmjs.org/react-dropzone/-/react-dropzone-20.0.0.tgz#75eade48bede945796aac3a25cba5488d5d640a9" + integrity sha512-Xw8tvvVPJQzj8ir5wivUMzA+G6R+aGhdU5KQzUMvVBlJNb26AW/0137VoYVmb5UgZcbhM9OCpjE4KOqqSL9QuQ== dependencies: - attr-accept "^2.2.4" - file-selector "^2.1.0" - prop-types "^15.8.1" + attr-accept "^2.2.5" + file-selector "^4.1.0" react-error-boundary@^6.1.2: version "6.1.2" @@ -8788,16 +8930,11 @@ semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.5.3: +semver@^7.5.3, semver@^7.7.1, semver@^7.7.3: version "7.8.5" resolved "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69" integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA== -semver@^7.7.1, semver@^7.7.3: - version "7.7.4" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" - integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== - set-function-length@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" @@ -9027,24 +9164,28 @@ stop-iteration-iterator@^1.1.0: es-errors "^1.3.0" internal-slot "^1.1.0" -storybook@10.3.5: - version "10.3.5" - resolved "https://registry.npmjs.org/storybook/-/storybook-10.3.5.tgz#77bc13217db7b3c2ba5a73c1f2d469bfc0675da1" - integrity sha512-uBSZu/GZa9aEIW3QMGvdQPMZWhGxSe4dyRWU8B3/Vd47Gy/XLC7tsBxRr13txmmPOEDHZR94uLuq0H50fvuqBw== +storybook@10.5.7: + version "10.5.7" + resolved "https://registry.npmjs.org/storybook/-/storybook-10.5.7.tgz#adfc465e51f337291c095278c23f1b8024ef2da7" + integrity sha512-oiKvWIwIoOhFP1i6dASYyMXwPHKEtVZMshqSB7EvIVYjWRh0l9H7gHEt1z4Gh2rLGFMekWdsm4s94rvwpR7gkg== dependencies: "@storybook/global" "^5.0.0" - "@storybook/icons" "^2.0.1" - "@testing-library/jest-dom" "^6.9.1" + "@storybook/icons" "^2.0.2" + "@testing-library/dom" "^10.4.1" + "@testing-library/jest-dom" "6.9.1" "@testing-library/user-event" "^14.6.1" "@vitest/expect" "3.2.4" "@vitest/spy" "3.2.4" "@webcontainer/env" "^1.1.1" - esbuild "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0" + esbuild "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0" + jsonc-parser "^3.3.1" open "^10.2.0" + oxc-parser "^0.127.0" + oxc-resolver "^11.19.1" recast "^0.23.5" semver "^7.7.3" use-sync-external-store "^1.5.0" - ws "^8.18.0" + ws "^8.21.1" strict-event-emitter@^0.5.1: version "0.5.1" @@ -9434,7 +9575,7 @@ tsconfig-paths@^4.2.0: minimist "^1.2.6" strip-bom "^3.0.0" -tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.4.0, tslib@^2.7.0, tslib@^2.8.0: +tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.4.0, tslib@^2.8.0: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== @@ -9946,10 +10087,10 @@ wrap-ansi@^7.0.0: string-width "^4.1.0" strip-ansi "^6.0.0" -ws@^8.18.0, ws@^8.19.0: - version "8.21.1" - resolved "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz#045650cd4b1207809e7547146223c3814a9af586" - integrity sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw== +ws@^8.19.0, ws@^8.21.1: + version "8.21.3" + resolved "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz#660b4faddb6a3e575c86e078126919961f4de4fc" + integrity sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw== wsl-utils@^0.1.0: version "0.1.0" From c6d3734b4989d9eb3e6e47e41225352f69058093 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:22:44 +0200 Subject: [PATCH 08/10] Update package.json dependencies and Node version Signed-off-by: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> --- package.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 89ce0d787153..a6f3527b81e2 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ }, "license": "AGPL-3.0", "engines": { - "node": "^22.22.2" + "node": "^22.22.0" }, "repository": { "type": "git", @@ -60,7 +60,7 @@ "@tiptap/extension-table": "^3.20.5", "@tiptap/pm": "^3.29.2", "@tiptap/react": "^3.20.5", - "@tiptap/starter-kit": "^3.29.2", + "@tiptap/starter-kit": "^3.20.5", "@vvo/tzdb": "^6.198.0", "apexcharts": "6.6.1", "axios": "1.18.1", @@ -91,7 +91,7 @@ "react-apexcharts": "2.1.1", "react-beautiful-dnd": "13.1.1", "react-dom": "19.2.8", - "react-dropzone": "20.0.0", + "react-dropzone": "15.0.0", "react-error-boundary": "^6.1.2", "react-hook-form": "^7.76.1", "react-hot-toast": "2.6.0", @@ -135,12 +135,12 @@ "eslint": "^9.39.4", "eslint-config-next": "^16.2.10", "eslint-config-prettier": "^10.1.8", - "jsdom": "30.0.1", + "jsdom": "29.0.1", "msw": "2.12.14", "msw-storybook-addon": "3.0.0", "playwright": "1.59.1", "prettier": "^3.9.6", - "storybook": "10.5.7", + "storybook": "10.3.5", "typescript": "5.9.3", "vite": "7.3.6", "vitest": "4.1.10" From 754de69d162dfcb3f09d8d130f6f29a95ea771e0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 24 Aug 2026 14:51:06 +0000 Subject: [PATCH 09/10] Merge pull request #366 from CyberDrain/dev fix: dev to hotfix Synced from CyberDrain/CIPP@8bf90d4a651bab76abf250e3baa66c70d8b2342c --- package.json | 14 +- public/secureScore.json | 2612 ++++++++++++++++- public/version.json | 4 +- .../CippBaselineStandardSettings.jsx | 34 +- .../CippAppRegistrationPermissions.jsx | 21 +- .../CippEnterpriseAppPermissions.jsx | 21 +- .../CippComponents/CippTranslations.jsx | 2 + .../CippTable/CippDataTableButton.jsx | 2 +- .../CippTable/CippMobileCardList.jsx | 2 + .../CippWizardAutopilotTypeSelection.jsx | 131 + .../CippWizard/CippWizardDevicePrepImport.jsx | 619 ++++ src/data/standards.json | 27 +- src/layouts/HeaderedTabbedLayout.jsx | 30 +- src/layouts/TabbedLayout.jsx | 11 +- src/layouts/config.js | 58 +- src/layouts/index.js | 18 +- .../email/tools/message-encryption/index.js | 13 +- .../endpoint/autopilot/add-device/index.js | 62 +- .../administration/guest-users/index.js | 2 + src/pages/teams-share/onedrive/index.js | 69 +- src/pages/tenant/baselines/template.jsx | 67 +- src/pages/tenant/baselines/templates/index.js | 255 +- src/pages/tenant/manage/user-defaults.js | 62 +- .../tenant/standards/templates/template.jsx | 13 +- .../tools/report-builder/builder/index.js | 36 + .../CippBaselineStandardItem.seeding.test.jsx | 93 + .../CippComponents/CippAddUserDrawer.test.jsx | 63 +- .../CippContainerManagement.test.jsx | 17 +- .../CippTable/CIPPTableToptoolbar.test.jsx | 17 +- .../CippTable/CippDataTable.stories.jsx | 37 + .../CippTable/CippDataTable.test.jsx | 139 +- ...ppWizardAutopilotTypeSelection.stories.jsx | 30 + .../CippWizardAutopilotTypeSelection.test.jsx | 75 + .../CippWizardDevicePrepImport.stories.jsx | 58 + .../CippWizardDevicePrepImport.test.jsx | 124 + tests/components/PrivateRoute.test.jsx | 1 + tests/mocks/api-call.js | 1 + tests/mocks/baseline-tenant-fixture.json | 613 ++++ .../BaselineTemplateEditor.seeding.test.jsx | 66 + tests/pages/MessageEncryptionPage.test.jsx | 34 +- tests/pages/UnauthenticatedPage.test.jsx | 1 + 41 files changed, 5262 insertions(+), 292 deletions(-) create mode 100644 src/components/CippWizard/CippWizardAutopilotTypeSelection.jsx create mode 100644 src/components/CippWizard/CippWizardDevicePrepImport.jsx create mode 100644 tests/components/CippBaselines/CippBaselineStandardItem.seeding.test.jsx create mode 100644 tests/components/CippWizard/CippWizardAutopilotTypeSelection.stories.jsx create mode 100644 tests/components/CippWizard/CippWizardAutopilotTypeSelection.test.jsx create mode 100644 tests/components/CippWizard/CippWizardDevicePrepImport.stories.jsx create mode 100644 tests/components/CippWizard/CippWizardDevicePrepImport.test.jsx create mode 100644 tests/mocks/baseline-tenant-fixture.json create mode 100644 tests/pages/BaselineTemplateEditor.seeding.test.jsx diff --git a/package.json b/package.json index a6f3527b81e2..8ba528f061ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cipp", - "version": "10.9.0", + "version": "10.9.1", "author": "CIPP Contributors", "homepage": "https://cipp.app/", "bugs": { @@ -8,7 +8,7 @@ }, "license": "AGPL-3.0", "engines": { - "node": "^22.22.0" + "node": "^22.22.2" }, "repository": { "type": "git", @@ -60,7 +60,7 @@ "@tiptap/extension-table": "^3.20.5", "@tiptap/pm": "^3.29.2", "@tiptap/react": "^3.20.5", - "@tiptap/starter-kit": "^3.20.5", + "@tiptap/starter-kit": "^3.29.2", "@vvo/tzdb": "^6.198.0", "apexcharts": "6.6.1", "axios": "1.18.1", @@ -91,7 +91,7 @@ "react-apexcharts": "2.1.1", "react-beautiful-dnd": "13.1.1", "react-dom": "19.2.8", - "react-dropzone": "15.0.0", + "react-dropzone": "20.0.0", "react-error-boundary": "^6.1.2", "react-hook-form": "^7.76.1", "react-hot-toast": "2.6.0", @@ -135,12 +135,12 @@ "eslint": "^9.39.4", "eslint-config-next": "^16.2.10", "eslint-config-prettier": "^10.1.8", - "jsdom": "29.0.1", + "jsdom": "30.0.1", "msw": "2.12.14", "msw-storybook-addon": "3.0.0", "playwright": "1.59.1", "prettier": "^3.9.6", - "storybook": "10.3.5", + "storybook": "10.5.7", "typescript": "5.9.3", "vite": "7.3.6", "vitest": "4.1.10" @@ -153,4 +153,4 @@ "resolutions": { "vite": "7.3.6" } -} +} \ No newline at end of file diff --git a/public/secureScore.json b/public/secureScore.json index 3e59bbd95d5d..9a3bf87d4774 100644 --- a/public/secureScore.json +++ b/public/secureScore.json @@ -49,7 +49,7 @@ "vendor": "Microsoft" }, "id": "aad_limited_administrative_roles", - "title": "Ensure \u0027Microsoft Azure Management\u0027 is limited to administrative roles" + "title": "Ensure 'Microsoft Azure Management' is limited to administrative roles" }, { "service": "AzureAD", @@ -62,7 +62,7 @@ "vendor": "Microsoft" }, "id": "aad_linkedin_connection_disables", - "title": "Ensure \u0027LinkedIn account connections\u0027 is disabled" + "title": "Ensure 'LinkedIn account connections' is disabled" }, { "service": "AzureAD", @@ -101,7 +101,7 @@ "vendor": "Microsoft" }, "id": "aad_phishing_MFA_strength", - "title": "Ensure \u0027Phishing-resistant MFA strength\u0027 is required for Administrators" + "title": "Ensure 'Phishing-resistant MFA strength' is required for Administrators" }, { "service": "AzureAD", @@ -129,6 +129,19 @@ "id": "aad_third_party_apps", "title": "Ensure third party integrated applications are not allowed" }, + { + "service": "Azure ATP", + "tier": "Core", + "userImpact": "high", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "AATP_AccountsInOperatorGroups", + "title": "Locate accounts in built-in Operator Groups" + }, { "service": "Azure ATP", "tier": "Core", @@ -296,7 +309,7 @@ "vendor": "Microsoft" }, "id": "AATP_ADCSMisconfiguredRpcEnrollmentSigning", - "title": "Enforce encryption for RPC certificate enrollment interface (ESC8)" + "title": "Enforce encryption for RPC certificate enrollment interface (ESC11)" }, { "service": "Azure ATP", @@ -441,19 +454,6 @@ "id": "AATP_CyberArk_HighNumberOfSystemAdmins", "title": "Limit the number of CyberArk Identity accounts with system admin role" }, - { - "service": "Azure ATP", - "tier": "Core", - "userImpact": "Medium", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "AATP_CyberArk_MFAforPriviledgedUserAccounts", - "title": "Assign multi-factor authentication for CyberArk Identity privileged user accounts " - }, { "service": "Azure ATP", "tier": "Core", @@ -532,6 +532,19 @@ "id": "AATP_DormantAccounts", "title": "Remove dormant accounts from sensitive groups" }, + { + "service": "Azure ATP", + "tier": "Core", + "userImpact": "high", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "AATP_EnsureNoPrivilegedSaasAppAccountsExistOutsideOfIdpControl", + "title": "Ensure no privileged SaaS apps accounts exist outside of IDP control" + }, { "service": "Azure ATP", "tier": "Core", @@ -610,6 +623,19 @@ "id": "AATP_ExposedPasswordsInADAttributes", "title": "Remove discoverable passwords in Active Directory account attributes" }, + { + "service": "Azure ATP", + "tier": "Core", + "userImpact": "high", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "AATP_ExternalAndGuestUsersWithPrivilegedRoles", + "title": "Ensure all External and Guest accounts are not granted privileged roles" + }, { "service": "Azure ATP", "tier": "Core", @@ -714,6 +740,32 @@ "id": "AATP_NonAdminDCSyncAccounts", "title": "Remove non-admin accounts with DCSync permissions " }, + { + "service": "Azure ATP", + "tier": "Core", + "userImpact": "high", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "AATP_NonAdminWithPasswordResetOnSensitiveGroups", + "title": "Ensure non-admin accounts cannot reset passwords for sensitive groups" + }, + { + "service": "Azure ATP", + "tier": "Core", + "userImpact": "high", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "AATP_NonAdminWriteDaclOnSensitiveGroups", + "title": "Ensure non-admin identities cannot Write DACL permissions on sensitive groups" + }, { "service": "Azure ATP", "tier": "Core", @@ -896,6 +948,19 @@ "id": "AATP_PrivilegedAccountsWithDelegationAllowed", "title": "Ensure privileged accounts are not delegated" }, + { + "service": "Azure ATP", + "tier": "Core", + "userImpact": "High", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "AATP_PrivilegedEntraIdAndActiveDirectoryAccounts", + "title": "Identify Entra ID privileged accounts that are also privileged in Active Directory" + }, { "service": "Azure ATP", "tier": "Core", @@ -909,6 +974,71 @@ "id": "AATP_PwdLAPS", "title": "Protect and manage local admin passwords with Microsoft LAPS" }, + { + "service": "Azure ATP", + "tier": "Core", + "userImpact": "Medium", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "AATP_SailPoint_DormantPriviledgedAccounts", + "title": "Remove stale SailPoint privileged accounts " + }, + { + "service": "Azure ATP", + "tier": "Core", + "userImpact": "Medium", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "AATP_SailPoint_HighNumberOfPriviledgedIdentityAccounts", + "title": "High number of SailPoint accounts with a privileged role assigned " + }, + { + "service": "Azure ATP", + "tier": "Core", + "userImpact": "Medium", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "AATP_SailPoint_HighNumberOfSystemAdmins", + "title": "Limit the number of SailPoint accounts with system admin role " + }, + { + "service": "Azure ATP", + "tier": "Core", + "userImpact": "Medium", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "AATP_SailPoint_MFAforPriviledgedUserAccounts", + "title": "Assign multi-factor authentication for SailPoint privileged user accounts " + }, + { + "service": "Azure ATP", + "tier": "Core", + "userImpact": "Medium", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "AATP_SailPoint_PriviledgedUserAccountsWithOldPasswords", + "title": "Change password for SailPoint privileged User accounts " + }, { "service": "Azure ATP", "tier": "Core", @@ -922,6 +1052,32 @@ "id": "AATP_Sensor", "title": "Install Defender for Identity Sensor on all Domain Controllers" }, + { + "service": "Azure ATP", + "tier": "Core", + "userImpact": "Medium", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "AATP_ServiceAccountsInPrivilegedGroup", + "title": " Identify service accounts in privileged groups" + }, + { + "service": "Azure ATP", + "tier": "Core", + "userImpact": "high", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "AATP_ServiceAccountsWithDomainOrGlobalAdminRole", + "title": "Ensure service accounts are not assigned Domain Name Admin or Global Admin roles" + }, { "service": "Azure ATP", "tier": "Core", @@ -948,6 +1104,19 @@ "id": "AATP_SingleManagedServiceAccountsWithOldPassword", "title": "Rotate old password for sMSA and set up valid rotation interval in the GPO" }, + { + "service": "Azure ATP", + "tier": "Core", + "userImpact": "medium", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "AATP_StaleAccounts", + "title": "Remove stale Active Directory accounts" + }, { "service": "Azure ATP", "tier": "Core", @@ -1011,7 +1180,7 @@ "vendor": "Microsoft" }, "id": "admincenter_owned_apps_and_services", - "title": "Ensure \u0027User owned apps and services\u0027 is restricted" + "title": "Ensure 'User owned apps and services' is restricted" }, { "service": "AzureAD", @@ -1102,7 +1271,7 @@ "vendor": "Microsoft" }, "id": "exo_individualsharing", - "title": "Ensure \u0027External sharing\u0027 of calendars is not available" + "title": "Ensure 'External sharing' of calendars is not available" }, { "service": "EXO", @@ -1388,7 +1557,7 @@ "vendor": "Microsoft" }, "id": "MDA_CitrixSF_LoginFailLockoutSecs", - "title": "Enhance \u0027login maximum attempts\u0027 - Lockout timer" + "title": "Enhance 'login maximum attempts' - Lockout timer" }, { "service": "MDA_CitrixSF", @@ -1401,7 +1570,7 @@ "vendor": "Microsoft" }, "id": "MDA_CitrixSF_LoginFailMaxAttempts", - "title": "Enhance \u0027login maximum attempts\u0027 - Number of attempts" + "title": "Enhance 'login maximum attempts' - Number of attempts" }, { "service": "MDA_CitrixSF", @@ -1518,7 +1687,7 @@ "vendor": "Microsoft" }, "id": "MDA_GitHub_DependencyInsights", - "title": "Disable \u0027Allow members to view dependency insights\u0027" + "title": "Disable 'Allow members to view dependency insights'" }, { "service": "MDA_GitHub", @@ -1531,7 +1700,7 @@ "vendor": "Microsoft" }, "id": "MDA_GitHub_EmailNotificationRestrictedToVerifiedOrApprovedDomains", - "title": "Enabled \u0027email notification delivery for this enterprise is restricted to verified or approved domains\u0027" + "title": "Enabled 'email notification delivery for this enterprise is restricted to verified or approved domains'" }, { "service": "MDA_GitHub", @@ -1557,7 +1726,7 @@ "vendor": "Microsoft" }, "id": "MDA_GitHub_OutsideCollabInvitation", - "title": "Disable \u0027Allow repository administrators to invite outside collaborators to repositories for this organization" + "title": "Disable 'Allow repository administrators to invite outside collaborators to repositories for this organization" }, { "service": "MDA_GitHub", @@ -1583,7 +1752,7 @@ "vendor": "Microsoft" }, "id": "MDA_GitHub_PublicRepoCreation", - "title": "Disable \u0027Members will be able to create public repositories, visible to anyone\u0027" + "title": "Disable 'Members will be able to create public repositories, visible to anyone'" }, { "service": "MDA_GitHub", @@ -1596,7 +1765,7 @@ "vendor": "Microsoft" }, "id": "MDA_GitHub_RepoTransferOrDeletion", - "title": "Disable \u0027members with admin permissions for repositories can delete or transfer repositories\u0027" + "title": "Disable 'members with admin permissions for repositories can delete or transfer repositories'" }, { "service": "MDA_GitHub", @@ -1609,7 +1778,7 @@ "vendor": "Microsoft" }, "id": "MDA_GitHub_RepoVisibility_change", - "title": "Disable \u0027Allow members to change repository visibilities for this organization\u0027" + "title": "Disable 'Allow members to change repository visibilities for this organization'" }, { "service": "MDA_GitHub", @@ -2701,7 +2870,7 @@ "vendor": "Microsoft" }, "id": "mdo_connectionfilter", - "title": "Don\u0027t add allowed IP addresses in the connection filter policy " + "title": "Don't add allowed IP addresses in the connection filter policy " }, { "service": "MDO", @@ -3039,7 +3208,7 @@ "vendor": "Microsoft" }, "id": "mdo_unusualcharacterssafetytips", - "title": "Enable the user impersonation unusual characters safety tip " + "title": "Enable the user impersonation unusual characters safety tip" }, { "service": "MDO", @@ -3286,7 +3455,7 @@ "vendor": "Microsoft" }, "id": "PWAgePolicyNew", - "title": "Ensure the \u0027Password expiration policy\u0027 is set to \u0027Set passwords to never expire (recommended)\u0027" + "title": "Ensure the 'Password expiration policy' is set to 'Set passwords to never expire (recommended)'" }, { "service": "AzureAD", @@ -3301,6 +3470,2385 @@ "id": "RoleOverlap", "title": "Use least privileged administrative roles" }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_100", + "title": "Disable JavaScript on Adobe Reader 2015" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_10000", + "title": "Disable insecure administration protocol Telnet" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_10001", + "title": "Require authentication for Telnet management interface" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_10002", + "title": "Remove insecure administration protocols SNMP V1 and SNMP V2" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_10003", + "title": "Require authentication for VNC management interface" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_101", + "title": "Disable JavaScript on Adobe 2015" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_102", + "title": "Enable 'Local Security Authority (LSA) protection' on Windows 11 22h2 and higher" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_103", + "title": "Require LDAP client signing to prevent tampering and protect directory authentication" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_104", + "title": "Encrypt LDAP client traffic to protect sensitive data in transit" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_105", + "title": "Enforce LDAP channel binding to protect authentication sessions from interception" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_106", + "title": "Require LDAP server signing to ensure integrity of directory traffic" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_107", + "title": "Block outbound network connections from Microsoft HTML Application Host (mshta.exe)" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_108", + "title": "Disable Remote Registry Service on Windows" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_109", + "title": "Disable NTLM authentication for Windows" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_110", + "title": "Block file transfer over RDP" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_111", + "title": "SMB server security hardening against authentication relay attacks" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_112", + "title": "Ensure devices are updated to Secure Boot 2023 certificates and boot manager" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_113", + "title": "Ensure LAPS is enabled on every endpoint and server" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_114", + "title": "Reduce unnecessary inbound internet exposure on internet-facing devices" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_115", + "title": "Ensure Microsoft Vulnerable Driver Blocklist is enabled" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_118", + "title": "scid_118" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_15", + "title": "Enable Automatic Updates" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_16", + "title": "Enable 'Hide Option to Enable or Disable Updates'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_17", + "title": "Disable 'Allow running plugins that are outdated'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_19", + "title": "Disable 'Continue running background apps when Google Chrome is closed'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_20", + "title": "Disable 'AutoFill'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2000", + "title": "Turn on Microsoft Defender for Endpoint sensor" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_20000", + "title": "Onboard devices to Microsoft Defender for Endpoint" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2001", + "title": "Fix Microsoft Defender for Endpoint sensor data collection" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2002", + "title": "Fix Microsoft Defender for Endpoint impaired communications" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2003", + "title": "Turn on Tamper Protection" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2004", + "title": "Enable EDR in block mode" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2010", + "title": "Turn on Microsoft Defender Antivirus" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2011", + "title": "Update Microsoft Defender Antivirus definitions" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2012", + "title": "Turn on real-time protection" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2013", + "title": "Turn on PUA protection in block mode" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2014", + "title": "Fix Windows Defender Antivirus cloud service connectivity" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2016", + "title": "Enable cloud-delivered protection" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2020", + "title": "Turn on all system-level Exploit protection settings" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2021", + "title": "Set controlled folder access to enabled or audit mode" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2030", + "title": "Update Microsoft Defender for Endpoint core components" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2060", + "title": "Set Microsoft Defender SmartScreen app and file checking to block or warn" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2061", + "title": "Set Microsoft Defender SmartScreen Microsoft Edge site and download checking to block or warn" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2070", + "title": "Turn on Microsoft Defender Firewall" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2071", + "title": "Secure Microsoft Defender Firewall domain profile" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2072", + "title": "Secure Microsoft Defender firewall private profile" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2073", + "title": "Secure Microsoft Defender Firewall public profile" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2080", + "title": "Turn on Microsoft Defender Credential Guard" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2090", + "title": "Encrypt all BitLocker-supported drives" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2091", + "title": "Resume BitLocker protection on all drives" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2093", + "title": "Ensure BitLocker drive compatibility" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_21", + "title": "Block webpages from automatically running Flash plugins" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2100", + "title": "Enable UEFI Secure Boot mode" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_22", + "title": "Disable 'Password Manager'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_23", + "title": "Enable 'Block third party cookies'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_24", + "title": "Set 'Remote Desktop security level' to 'TLS'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_25", + "title": "Enable 'Local Security Authority (LSA) protection'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2500", + "title": "Block executable content from email client and webmail" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2501", + "title": "Block all Office applications from creating child processes" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2502", + "title": "Block Office applications from creating executable content" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2503", + "title": "Block Office applications from injecting code into other processes" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2504", + "title": "Block JavaScript or VBScript from launching downloaded executable content" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2505", + "title": "Block execution of potentially obfuscated scripts" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2506", + "title": "Block Win32 API calls from Office macros" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2507", + "title": "Block executable files from running unless they meet a prevalence, age, or trusted list criterion" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2508", + "title": "Use advanced protection against ransomware" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2509", + "title": "Block credential stealing from the Windows local security authority subsystem (lsass.exe)" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2510", + "title": "Block process creations originating from PSExec and WMI commands" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2511", + "title": "Block untrusted and unsigned processes that run from USB" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2512", + "title": "Block Office communication application from creating child processes" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2513", + "title": "Block Adobe Reader from creating child processes" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2514", + "title": "Block persistence through WMI event subscription" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2515", + "title": "Block abuse of exploited vulnerable signed drivers" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2516", + "title": "Block Webshell creation for Servers" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2517", + "title": "Block use of copied or impersonated system tools" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_2518", + "title": "Block rebooting machine in Safe Mode" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_26", + "title": "Enable 'Safe DLL Search Mode'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_27", + "title": "Set User Account Control (UAC) to automatically deny elevation requests" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_28", + "title": "Set 'Interactive logon: Machine inactivity limit' to '1-900 seconds'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_29", + "title": "Disable 'Enumerate administrator accounts on elevation'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_30", + "title": "Disable 'Insecure guest logons' in SMB" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_3001", + "title": "Fix unquoted service path for Windows services" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_3002", + "title": "Change service executable path to a common protected location" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_3003", + "title": "Change service account to avoid cached password in windows registry" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_3010", + "title": "Disable the built-in Administrator account" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_3011", + "title": "Disable the built-in Guest account" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_32", + "title": "Set 'Minimum password length' to '14 or more characters'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_33", + "title": "Set 'Enforce password history' to '24 or more password(s)'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_34", + "title": "Set 'Maximum password age' to '60 or fewer days, but not 0'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_35", + "title": "Set 'Minimum password age' to '1 or more day(s)'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_36", + "title": "Enable 'Domain member: Require strong (Windows 2000 or later) session key'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_37", + "title": "Enable 'Domain member: Digitally encrypt or sign secure channel data (always)'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_38", + "title": "Enable Set 'Domain member: Digitally encrypt secure channel data (when possible)'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_39", + "title": "Enable 'Domain member: Digitally sign secure channel data (when possible)'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_40", + "title": "Disable 'Domain member: Disable machine account password changes'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_4000", + "title": "Disallow offline access to shares" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_4001", + "title": "Remove share write permission set to Everyone" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_4002", + "title": "Remove shares from the root folder" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_4003", + "title": "Set folder access-based enumeration for shares" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_41", + "title": "Set 'Account lockout duration' to 15 minutes or more" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_42", + "title": "Set 'Reset account lockout counter after' to 15 minutes or more" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_43", + "title": "Disable Microsoft Defender Firewall notifications when programs are blocked for Domain profile" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_44", + "title": "Set 'Account lockout threshold' to 1-10 invalid login attempts" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_45", + "title": "Set user authentication for remote connections by using Network Level Authentication to 'Enabled'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_46", + "title": "Disable Microsoft Defender Firewall notifications when programs are blocked for Private profile" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_49", + "title": "Disable Microsoft Defender Firewall notifications when programs are blocked for Public profile" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_50", + "title": "Disable merging of local Microsoft Defender Firewall rules with group policy firewall rules for the Public profile" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_5001", + "title": "Fix Microsoft Defender for Endpoint sensor data collection in macOS" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_5002", + "title": "Fix Microsoft Defender for Endpoint impaired communications in macOS" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_5003", + "title": "Set minimum password length to 15 or more characters in macOS" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_5004", + "title": "Set 'Enforce password history' to '24 or more password(s)' in macOS" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_5005", + "title": "Set 'Maximum password age' to '90 or fewer days, but not 0' in macOS" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_5006", + "title": "Set account lockout threshold to 5 or lower in macOS" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_5007", + "title": "Turn on Firewall in macOS" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_5009", + "title": "Enable Gatekeeper in macOS" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_5010", + "title": "Enable System Integrity Protection (SIP) in macOS" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_5011", + "title": "Enable FileVault Disk Encryption in macOS" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_5013", + "title": "Ensure screensaver is set to start in 20 minutes or less in macOS" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_5014", + "title": "Secure Home Folders in macOS" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_5090", + "title": "Turn on Microsoft Defender Antivirus real-time protection in macOS" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_5091", + "title": "Turn on Microsoft Defender Antivirus PUA protection in block mode in macOS" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_5092", + "title": "Turn on Tamper Protection for MacOS" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_5093", + "title": "Enable Microsoft Defender Antivirus real-time behavior monitoring in macOS" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_5094", + "title": "Enable Microsoft Defender Antivirus cloud-delivered protection in macOS" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_5095", + "title": "Update Microsoft Defender Antivirus definitions in macOS" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_51", + "title": "Disable merging of local Microsoft Defender Firewall connection rules with group policy firewall rules for the Public profile" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_52", + "title": "Enable 'Apply UAC restrictions to local accounts on network logons'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_53", + "title": "Disable SMBv1 client driver" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_54", + "title": "Disable SMBv1 server" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_55", + "title": "Disable 'Network access: Let Everyone permissions apply to anonymous users'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_57", + "title": "Disable 'WDigest Authentication'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_58", + "title": "Disable 'Installation and configuration of Network Bridge on your DNS domain network'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_59", + "title": "Enable 'Require domain users to elevate when setting a network's location'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_60", + "title": "Prohibit use of Internet Connection Sharing on your DNS domain network" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_6001", + "title": "Fix Microsoft Defender for Endpoint sensor data collection for Linux" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_6002", + "title": "Fix Microsoft Defender for Endpoint impaired communications for Linux" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_6014", + "title": "Unrestricted Access Accounts for Linux" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_6090", + "title": "Turn on Microsoft Defender Antivirus real-time protection for Linux" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_6091", + "title": "Turn on Microsoft Defender Antivirus PUA protection in block mode for Linux" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_6092", + "title": "Turn on Microsoft Defender Antivirus Tamper Protection for Linux" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_6093", + "title": "Enable Microsoft Defender Antivirus real-time behavior monitoring for Linux" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_6094", + "title": "Enable Microsoft Defender Antivirus cloud-delivered protection for Linux" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_6095", + "title": "Update Microsoft Defender Antivirus definitions for Linux" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_61", + "title": "Set 'Minimum PIN length for startup' to '6 or more characters'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_6100", + "title": "Enable 'Microsoft Defender for Endpoint Plug-in for WSL'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_6101", + "title": "Turn off custom kernel/commandline in Windows Subsystem for Linux" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_62", + "title": "Enable 'Require additional authentication at startup'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_63", + "title": "Disable 'Configure Offer Remote Assistance'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_64", + "title": "Restrict anonymous access to named pipes and Shares" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_65", + "title": "Disable 'Store LAN Manager hash value on next password change'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_66", + "title": "Disable 'Always install with elevated privileges'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_67", + "title": "Disable 'Autoplay for non-volume devices'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_68", + "title": "Disable 'Anonymous enumeration of SAM accounts'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_69", + "title": "Disable 'Autoplay' for all drives" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_70", + "title": "Set default behavior for 'AutoRun' to 'Enabled: Do not execute any autorun commands'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_71", + "title": "Enable 'Limit local account use of blank passwords to console logon only'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_72", + "title": "Set LAN Manager authentication level to 'Send NTLMv2 response only. Refuse LM & NTLM'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_73", + "title": "Disable 'Allow Basic authentication' for WinRM Client" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_74", + "title": "Disable 'Allow Basic authentication' for WinRM Service" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_75", + "title": "Disable Flash on Adobe Reader DC" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_76", + "title": "Disable JavaScript on Adobe Reader DC" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_77", + "title": "Disable Flash on Adobe Acrobat Pro XI" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_78", + "title": "Disable JavaScript on Adobe Acrobat Pro XI" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_79", + "title": "Disable running or installing downloaded software with invalid signature" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_80", + "title": "Block Flash activation in Office documents" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_81", + "title": "Set IPv6 source routing to highest protection" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_82", + "title": "Disable IP source routing" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_83", + "title": "Enable Explorer Data Execution Prevention (DEP)" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_85", + "title": "Block outdated ActiveX controls for Internet Explorer" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_87", + "title": "Disable Solicited Remote Assistance" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_88", + "title": "Disable Anonymous enumeration of shares" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_89", + "title": "Enable scanning of removable drives during a full scan" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_9", + "title": "Enable 'Local Machine Zone Lockdown Security'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_90", + "title": "Enable Microsoft Defender Antivirus email scanning" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_91", + "title": "Enable Microsoft Defender Antivirus real-time behavior monitoring" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_92", + "title": "Enable Microsoft Defender Antivirus scanning of downloaded files and attachments" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_93", + "title": "Disable the local storage of passwords and credentials" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_94", + "title": "Disable sending unencrypted password to third-party SMB servers" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_95", + "title": "Enable 'Microsoft network client: Digitally sign communications (always)'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_96", + "title": "Enable 'Network Protection'" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_97", + "title": "Disable JavaScript on Adobe DC" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_98", + "title": "Disable JavaScript on Adobe Reader 2017" + }, + { + "service": "MDATP", + "tier": "Core", + "userImpact": "Unknown", + "vendorInformation": { + "provider": "SecureScore", + "providerVersion": null, + "subProvider": null, + "vendor": "Microsoft" + }, + "id": "scid_99", + "title": "Disable JavaScript on Adobe Acrobat 2017" + }, { "service": "AzureAD", "tier": "Defense In Depth", @@ -3312,7 +5860,7 @@ "vendor": "Microsoft" }, "id": "SelfServicePasswordReset", - "title": "Ensure \u0027Self service password reset enabled\u0027 is set to \u0027All\u0027" + "title": "Ensure 'Self service password reset enabled' is set to 'All'" }, { "service": "AzureAD", @@ -3364,7 +5912,7 @@ "vendor": "Microsoft" }, "id": "spo_external_users_sharing", - "title": "Ensure that SharePoint guest users cannot share items they don\u0027t own" + "title": "Ensure that SharePoint guest users cannot share items they don't own" }, { "service": "SPO", diff --git a/public/version.json b/public/version.json index b4196392f644..0453cde03069 100644 --- a/public/version.json +++ b/public/version.json @@ -1,3 +1,3 @@ { - "version": "10.9.0" -} + "version": "10.9.1" +} \ No newline at end of file diff --git a/src/components/CippBaselines/CippBaselineStandardSettings.jsx b/src/components/CippBaselines/CippBaselineStandardSettings.jsx index abc76cf7926b..5683be389001 100644 --- a/src/components/CippBaselines/CippBaselineStandardSettings.jsx +++ b/src/components/CippBaselines/CippBaselineStandardSettings.jsx @@ -30,20 +30,33 @@ export const CippBaselineStandardSettings = ({ }) => { const variableEntries = Object.entries(standard?.variables ?? {}) - // Seed each field: current applied value > recommended > default. Api-driven + // Seed value per field: current applied value > recommended > default. Api-driven // autoCompletes (e.g. the CA template picker) seed with a bare value; the // autocomplete resolves the label once its option list loads. + const resolveSeed = (key, definition) => { + const seed = + initialValues?.[key] ?? definition.recommended ?? definition.default + if (seed === undefined) return undefined + return definition.type === 'autoComplete' + ? (definition.options?.find((option) => option.value === seed) ?? seed) + : seed + } + + // The seed is ALSO passed as each field's defaultValue below - that is the load-bearing + // path. CippFormComponent's Controllers register with defaultValue '' during render, so + // by the time this effect runs on a lazily-mounted details pane (accordion expand), the + // form already holds '' for every field and a plain undefined-check never seeds. That is + // exactly how a saved baseline's variables rendered blank in the editor. The effect stays + // for values a Controller default cannot reach (a form reset that wipes mounted fields): + // it re-seeds untouched empties but never overwrites a value the operator typed. useEffect(() => { variableEntries.forEach(([key, definition]) => { const name = `${namePrefix}.${key}` - const seed = - initialValues?.[key] ?? definition.recommended ?? definition.default - if (formControl.getValues(name) === undefined && seed !== undefined) { - const seedValue = - definition.type === 'autoComplete' - ? (definition.options?.find((option) => option.value === seed) ?? - seed) - : seed + const seedValue = resolveSeed(key, definition) + if (seedValue === undefined || seedValue === '') return + const currentValue = formControl.getValues(name) + const untouched = !formControl.getFieldState(name).isDirty + if (currentValue === undefined || (currentValue === '' && untouched)) { formControl.setValue(name, seedValue) } }) @@ -67,6 +80,9 @@ export const CippBaselineStandardSettings = ({ name={`${namePrefix}.${key}`} label={definition.label} formControl={formControl} + // Saved/recommended value rides the Controller's own defaultValue so a + // lazily-mounted field initializes correctly regardless of effect order. + defaultValue={resolveSeed(key, definition)} options={definition.options} // Definitions may source options from an API instead of a static list // (e.g. the CA template picker) - CippFormComponent handles the fetch. diff --git a/src/components/CippComponents/CippAppRegistrationPermissions.jsx b/src/components/CippComponents/CippAppRegistrationPermissions.jsx index 09c209bde948..6d432c3d5103 100644 --- a/src/components/CippComponents/CippAppRegistrationPermissions.jsx +++ b/src/components/CippComponents/CippAppRegistrationPermissions.jsx @@ -161,8 +161,18 @@ const ResourcePermissionsAccordion = ({ }} disableGutters > - }> - + } + // summary is a centered ButtonBase, an unshrinkable row spills both edges + sx={{ "& .MuiAccordionSummary-content": { minWidth: 0 } }} + > + {title} @@ -180,7 +190,12 @@ const ResourcePermissionsAccordion = ({ /> )} - + diff --git a/src/components/CippComponents/CippEnterpriseAppPermissions.jsx b/src/components/CippComponents/CippEnterpriseAppPermissions.jsx index 7538755ecef5..7b283ba99bde 100644 --- a/src/components/CippComponents/CippEnterpriseAppPermissions.jsx +++ b/src/components/CippComponents/CippEnterpriseAppPermissions.jsx @@ -191,8 +191,18 @@ const ResourceAccordion = ({ title, resourceId, chipLabel, children, riskSummary }} disableGutters > - }> - + } + // summary is a centered ButtonBase, an unshrinkable row spills both edges + sx={{ "& .MuiAccordionSummary-content": { minWidth: 0 } }} + > + {title} @@ -210,7 +220,12 @@ const ResourceAccordion = ({ title, resourceId, chipLabel, children, riskSummary /> )} - + {chipLabel != null && } diff --git a/src/components/CippComponents/CippTranslations.jsx b/src/components/CippComponents/CippTranslations.jsx index 4aab78290cd5..b184235e8072 100644 --- a/src/components/CippComponents/CippTranslations.jsx +++ b/src/components/CippComponents/CippTranslations.jsx @@ -122,4 +122,6 @@ export const CippTranslations = { resellerPartnerDelegatedAdmin: 'Direct Reseller', valueAddedResellerPartnerDelegatedAdmin: 'Indirect Reseller', unknownFutureValue: 'Unknown', + devicePrepData: 'Corporate Identifiers', + overwriteExisting: 'Overwrite Existing Identifiers', } diff --git a/src/components/CippTable/CippDataTableButton.jsx b/src/components/CippTable/CippDataTableButton.jsx index 9c99484e72e9..aa8702a17448 100644 --- a/src/components/CippTable/CippDataTableButton.jsx +++ b/src/components/CippTable/CippDataTableButton.jsx @@ -37,7 +37,7 @@ const CippDataTableButton = ({ const settings = useSettings(); const isLive = Boolean(api?.url); - const nestedTitle = tableProps.title ?? tableTitle ?? title ?? "Data"; + const nestedTitle = title ?? tableTitle ?? "Data"; const handleOpenStaticDialog = (event) => { event?.stopPropagation(); diff --git a/src/components/CippTable/CippMobileCardList.jsx b/src/components/CippTable/CippMobileCardList.jsx index f817177544bd..fa208ae5eb2f 100644 --- a/src/components/CippTable/CippMobileCardList.jsx +++ b/src/components/CippTable/CippMobileCardList.jsx @@ -285,6 +285,8 @@ export const CippMobileCardList = (props) => { display: "inline-flex", alignItems: "center", gap: 0.5, + // long column names: caption ellipsizes, icon stays inside the card clip + maxWidth: "100%", ...(isBareBoolean && { border: 1, borderColor: "divider", diff --git a/src/components/CippWizard/CippWizardAutopilotTypeSelection.jsx b/src/components/CippWizard/CippWizardAutopilotTypeSelection.jsx new file mode 100644 index 000000000000..1c2bf3ae554a --- /dev/null +++ b/src/components/CippWizard/CippWizardAutopilotTypeSelection.jsx @@ -0,0 +1,131 @@ +import { + Avatar, + Card, + CardContent, + Stack, + SvgIcon, + Typography, +} from '@mui/material' +import { useState, useEffect } from 'react' +import { CippWizardStepButtons } from './CippWizardStepButtons' +import { + IdentificationIcon, + RocketLaunchIcon, +} from '@heroicons/react/24/outline' + +export const CippWizardAutopilotTypeSelection = (props) => { + const { onNextStep, formControl, currentStep, onPreviousStep } = props + + const [selectedOption, setSelectedOption] = useState(() => + formControl.getValues('deploymentType') + ) + + // Register the deploymentType field in react-hook-form + formControl.register('deploymentType', { + required: true, + }) + + useEffect(() => { + if (formControl.getValues('deploymentType')) { + formControl.trigger('deploymentType') + } + }, [formControl]) + + const handleOptionClick = (value) => { + setSelectedOption(value) + formControl.setValue('deploymentType', value) + + // Clear the other path's fields so switching back and forth doesn't submit + // stale device data or keep its validation rules active + if (value === 'autopilot') { + formControl.unregister('devicePrepData') + formControl.unregister('overwriteExisting') + } else if (value === 'devicePrep') { + formControl.unregister('autopilotData') + formControl.unregister('GroupName') + } + + formControl.trigger() + } + + const options = [ + { + value: 'autopilot', + label: 'Windows Autopilot', + description: + 'Upload devices to Windows Autopilot using their serial number, product ID or hardware hash.', + icon: , + }, + { + value: 'devicePrep', + label: 'Device Preparation (Corporate Identifiers)', + description: + 'Upload corporate device identifiers (manufacturer, model and serial number) so devices are recognized as corporate-owned and can enroll using Windows Autopilot device preparation.', + icon: , + }, + ] + + return ( + + + Select Deployment Type + + Choose how you want to register the devices for this tenant. + + + + {options.map((option) => { + const isSelected = selectedOption === option.value + + return ( + handleOptionClick(option.value)} + variant="outlined" + sx={{ + cursor: 'pointer', + ...(isSelected && { + boxShadow: (theme) => + `0px 0px 0px 2px ${theme.palette.primary.main}`, + }), + '&:hover': { + ...(isSelected ? {} : { boxShadow: 8 }), + }, + }} + > + + + + {option.icon} + + + {option.label} + + {option.description} + + + + + + ) + })} + + + + ) +} + +export default CippWizardAutopilotTypeSelection diff --git a/src/components/CippWizard/CippWizardDevicePrepImport.jsx b/src/components/CippWizard/CippWizardDevicePrepImport.jsx new file mode 100644 index 000000000000..157b525977b0 --- /dev/null +++ b/src/components/CippWizard/CippWizardDevicePrepImport.jsx @@ -0,0 +1,619 @@ +import { + Button, + Link, + Stack, + Box, + Typography, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + Alert, + Paper, + IconButton, +} from '@mui/material' +import { CippWizardStepButtons } from './CippWizardStepButtons' +import CippFormComponent from '../CippComponents/CippFormComponent' +import { CippDataTable } from '../CippTable/CippDataTable' +import { useWatch } from 'react-hook-form' +import { Delete, FileDownload, Upload, Add } from '@mui/icons-material' +import { useEffect, useState } from 'react' +import React from 'react' +import { useIsMobileLayout } from '../../hooks/use-breakpoint' + +// Modified version of CippWizardAutopilotImport for corporate device identifiers +// (Autopilot device preparation): every device is a manufacturer, model and serial +// number triplet that Graph combines into a single comma-separated identifier, so +// all three fields are required and none of them may contain a comma. +export const CippWizardDevicePrepImport = (props) => { + const { + onNextStep, + formControl, + currentStep, + onPreviousStep, + fields, + name, + fileName = 'template', + } = props + const tableData = useWatch({ control: formControl.control, name: name }) + // Seed from the form so navigating back to this step keeps the imported rows + const [newTableData, setTableData] = useState( + () => formControl.getValues(name) || [] + ) + const fileInputRef = React.useRef(null) + const [manualDialogOpen, setManualDialogOpen] = useState(false) + const [manualInputs, setManualInputs] = useState([{}]) + const inputRefs = React.useRef([]) + const isMobile = useIsMobileLayout() + const [validationErrors, setValidationErrors] = useState([]) + const [importErrors, setImportErrors] = useState([]) + + // At least one identifier is needed before the wizard can continue + formControl.register(name, { + validate: (value) => Array.isArray(value) && value.length > 0, + }) + + const handleRemoveItem = (row) => { + if (row === undefined) return false + const index = tableData?.findIndex((item) => item === row) + const newTableData = [...tableData] + newTableData.splice(index, 1) + setTableData(newTableData) + } + + const collectRowErrors = (rows) => { + const errors = [] + const seenIdentifiers = new Set() + + rows.forEach((row, index) => { + const missingFields = fields.filter( + (field) => + !row[field.propertyName] || row[field.propertyName].trim() === '' + ) + if (missingFields.length > 0) { + errors.push( + `Row ${index + 1}: ${missingFields.map((f) => f.friendlyName).join(', ')} ${ + missingFields.length === 1 ? 'is' : 'are' + } required` + ) + return + } + + const commaFields = fields.filter((field) => + row[field.propertyName].includes(',') + ) + if (commaFields.length > 0) { + errors.push( + `Row ${index + 1}: ${commaFields + .map((f) => f.friendlyName) + .join(', ')} may not contain a comma` + ) + return + } + + const identifier = fields + .map((field) => row[field.propertyName].trim().toLowerCase()) + .join(',') + if (seenIdentifiers.has(identifier)) { + errors.push(`Row ${index + 1}: Duplicate device "${identifier}"`) + } + seenIdentifiers.add(identifier) + }) + + return errors + } + + const handleFileSelect = (event) => { + const file = event.target.files[0] + if (file) { + const reader = new FileReader() + reader.onload = (e) => { + const text = e.target.result + const lines = text.split('\n') + const firstLine = lines[0].split(',').map((header) => header.trim()) + + // Check if this is a headerless CSV (no recognizable headers). The Intune + // portal's corporate identifier CSV has no header row. + const hasHeaders = firstLine.some((header) => { + return fields.some( + (field) => + header === field.propertyName || + header === field.friendlyName || + (field.alternativePropertyNames && + field.alternativePropertyNames.includes(header)) + ) + }) + + let headers, headerMapping + + if (hasHeaders) { + headers = firstLine + + // Create mapping for property names and alternative property names + headerMapping = {} + fields.forEach((field) => { + headerMapping[field.propertyName] = field.propertyName + headerMapping[field.friendlyName] = field.propertyName + if (field.alternativePropertyNames) { + field.alternativePropertyNames.forEach((altName) => { + headerMapping[altName] = field.propertyName + }) + } + }) + + // All three columns are required for corporate identifiers + const missingColumns = fields.filter((field) => { + const hasPropertyName = headers.includes(field.propertyName) + const hasFriendlyName = headers.includes(field.friendlyName) + const hasAlternativeName = field.alternativePropertyNames + ? field.alternativePropertyNames.some((altName) => + headers.includes(altName) + ) + : false + return !hasPropertyName && !hasFriendlyName && !hasAlternativeName + }) + + if (missingColumns.length > 0) { + const missingFormats = missingColumns + .map((f) => { + const formats = [f.propertyName, f.friendlyName] + if (f.alternativePropertyNames) { + formats.push(...f.alternativePropertyNames) + } + return `"${formats.join('" or "')}"` + }) + .join(', ') + setImportErrors([ + `CSV is missing required columns: ${missingFormats}`, + ]) + return + } + } else { + // Headerless CSV - assume the Intune portal order: manufacturer, model, serial number + headers = fields.map((field) => field.propertyName) + headerMapping = {} + headers.forEach((header) => { + headerMapping[header] = header + }) + + if (firstLine.length < fields.length) { + setImportErrors([ + `Headerless CSV must have ${fields.length} columns in order: ${fields + .map((f) => f.friendlyName) + .join(', ')}`, + ]) + return + } + } + + const data = lines + .slice(hasHeaders ? 1 : 0) // Skip first line only if it has headers + .filter((line) => line.trim() !== '') // Remove empty lines + .map((line) => { + const values = line.split(',') + const row = fields.reduce((obj, field) => { + obj[field.propertyName] = '' + return obj + }, {}) + headers.forEach((header, i) => { + const propertyName = headerMapping[header] + if (propertyName) { + row[propertyName] = values[i]?.trim() || '' + } + }) + return row + }) + + const errors = collectRowErrors(data) + if (errors.length > 0) { + setImportErrors(errors) + return + } + + setImportErrors([]) + setTableData(data) + formControl.setValue(name, data, { shouldValidate: true }) + } + reader.readAsText(file) + } + } + + const handleManualInputChange = (rowIndex, field, value) => { + setManualInputs((prev) => { + const newInputs = [...prev] + if (!newInputs[rowIndex]) { + newInputs[rowIndex] = {} + } + newInputs[rowIndex][field] = value + return newInputs + }) + } + + const handleAddRow = () => { + setManualInputs((prev) => [...prev, {}]) + } + + const validateRows = (rows) => { + const errors = collectRowErrors( + rows.filter((row) => + Object.values(row).some((value) => value && value.trim() !== '') + ) + ) + setValidationErrors(errors) + return errors.length === 0 + } + + const handleManualAdd = () => { + const newRows = manualInputs + .filter((row) => + Object.values(row).some((value) => value && value.trim() !== '') + ) + .map((row) => { + return fields.reduce((obj, field) => { + obj[field.propertyName] = row[field.propertyName] || '' + return obj + }, {}) + }) + + if (newRows.length === 0) { + setManualDialogOpen(false) + setManualInputs([{}]) + return + } + + if (!validateRows(newRows)) { + return + } + + const updatedData = [...(tableData || []), ...newRows] + setTableData(updatedData) + formControl.setValue(name, updatedData, { shouldValidate: true }) + setManualInputs([{}]) + setManualDialogOpen(false) + } + + const handleDialogClose = () => { + setManualDialogOpen(false) + setManualInputs([{}]) + } + + const lastField = fields[fields.length - 1] + + const handleKeyPress = (event, rowIndex) => { + if ( + event.key === 'Enter' && + manualInputs[rowIndex]?.[lastField.propertyName] + ) { + if (rowIndex === manualInputs.length - 1) { + const newRowIndex = manualInputs.length + setManualInputs((prev) => [...prev, {}]) + // Wait for the next render cycle to set focus + setTimeout(() => { + const newInput = + inputRefs.current[newRowIndex]?.[fields[0].propertyName] + if (newInput) { + newInput.focus() + } + }, 0) + } + } + } + + const handleRemoveRow = (rowIndex) => { + setManualInputs((prev) => prev.filter((_, index) => index !== rowIndex)) + } + + useEffect(() => { + formControl.setValue(name, newTableData, { + shouldValidate: true, + }) + }, [newTableData]) + + // Add effect to validate rows when manualInputs changes + useEffect(() => { + validateRows(manualInputs) + }, [manualInputs]) + + const actions = [ + { + icon: , + label: 'Delete Row', + confirmText: 'Are you sure you want to delete this row?', + customFunction: handleRemoveItem, + noConfirm: true, + }, + ] + + return ( + + {importErrors.length > 0 && ( + setImportErrors([])}> + + The file could not be imported: + + {importErrors.map((error, index) => ( + + • {error} + + ))} + + )} + f.propertyName)} + cardButton={ + + + + + + + } + /> + + + + + Manual Import + + + {validationErrors.length > 0 && ( + + + Please fix the following validation errors: + + {validationErrors.map((error, index) => ( + + • {error} + + ))} + + )} + {manualInputs.map((row, rowIndex) => { + // Defined once and placed by either branch, so the two layouts cannot drift. + const fieldInputs = fields.map((field) => ( + + { + if (!inputRefs.current[rowIndex]) { + inputRefs.current[rowIndex] = {} + } + inputRefs.current[rowIndex][field.propertyName] = el + }} + label={field.friendlyName} + value={row[field.propertyName] || ''} + onChange={(e) => + handleManualInputChange( + rowIndex, + field.propertyName, + e.target.value + ) + } + onKeyDown={(e) => + field.propertyName === lastField.propertyName && + handleKeyPress(e, rowIndex) + } + fullWidth + size="small" + /> + + )) + + const rowNumber = ( + + {rowIndex + 1} + + ) + + // Below md one row becomes one card instead of a horizontal scroller. + if (isMobile) { + return ( + + + {rowNumber} + + Device {rowIndex + 1} + + handleRemoveRow(rowIndex)} + disabled={manualInputs.length === 1} + color="error" + aria-label={`Remove device ${rowIndex + 1}`} + > + + + + {fieldInputs} + + ) + } + + return ( + + {rowNumber} + {fieldInputs} + + + ) + })} + + + + + + + + + + + + + + ) +} diff --git a/src/data/standards.json b/src/data/standards.json index d4a68dc0e98c..57c45550d2fb 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -5173,6 +5173,27 @@ "ONEDRIVE_ENTERPRISE" ] }, + { + "name": "standards.OneDriveLicensedQuota", + "cat": "SharePoint Standards", + "tag": [], + "helpText": "Raises the OneDrive storage quota to 5 TB for users whose license includes that entitlement. Microsoft provisions every OneDrive at 1 TB and does not apply the licensed entitlement automatically. Users already at or above 5 TB, for example raised further by Microsoft support, are left untouched.", + "docsDescription": "Microsoft provisions every OneDrive with a 1 TB quota regardless of license. Users holding OneDrive for Business (Plan 2), SharePoint Online (Plan 2), or a bundle that includes one of these (Microsoft 365/Office 365 E3/E5, A3/A5, G3/G5) are entitled to 5 TB, but an admin has to raise the quota manually. This standard finds enabled users with a qualifying service plan whose OneDrive quota is below 5 TB and raises it to 5 TB, with the warning level at 90%. Users whose quota is already at or above 5 TB, for example increased to 25 TB by Microsoft support, are skipped. Microsoft only permits quotas above 1 TB when the subscription has five or more users on a qualifying plan, so tenants below that threshold are reported as compliant and left unchanged.", + "executiveText": "Ensures employees receive the full OneDrive storage their licenses already include. Microsoft grants 5 TB of storage with most enterprise licenses but only provisions 1 TB by default, leaving paid-for capacity unused. Automatically correcting the allocation prevents storage shortages and support tickets without any additional licensing cost.", + "addedComponent": [], + "label": "Raise OneDrive storage quota for entitled users", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2026-08-21", + "powershellEquivalent": "Set-SPOSite -Identity https://tenant-my.sharepoint.com/personal/user -StorageQuota 5242880", + "recommendedBy": [], + "requiredCapabilities": [ + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "SHAREPOINTENTERPRISE_GOV", + "ONEDRIVEENTERPRISE" + ] + }, { "name": "standards.SPFileRequests", "cat": "SharePoint Standards", @@ -7808,15 +7829,15 @@ "name": "standards.MessageEncryption", "cat": "Exchange Standards", "tag": [], - "helpText": "Enables Microsoft Purview Message Encryption by turning on Azure RMS licensing for Exchange Online. Skipped with a warning when the tenant still points at an on-premises AD RMS cluster, because AD RMS has to be migrated to Azure RMS first. This standard only turns the feature on: branding, one-time passcodes, and social ID sign-in for encrypted messages are configured in the [Configure Encrypted Message Branding (OME)](https://standards.cipp.app/standards/omebranding) standard. [Read more](https://learn.microsoft.com/en-us/purview/set-up-new-message-encryption-capabilities)", - "docsDescription": "Sets AzureRMSLicensingEnabled to true, the only prerequisite for Microsoft Purview Message Encryption. Reports the IRM licensing state per tenant, including the licensing location, so you can see at a glance which tenants have message encryption available. Remediation is deliberately skipped for tenants with an on-premises AD RMS licensing location, as Purview Message Encryption is not compatible with AD RMS and those tenants need to be migrated to Azure RMS first.", + "helpText": "Enables Microsoft Purview Message Encryption by turning on Azure RMS licensing for Exchange Online, and turns on simplified client access so the Encrypt button appears in Outlook on the web and the new Outlook. Skipped with a warning when the tenant still points at an on-premises AD RMS cluster, because AD RMS has to be migrated to Azure RMS first. This standard only turns the feature on: branding, one-time passcodes, and social ID sign-in for encrypted messages are configured in the [Configure Encrypted Message Branding (OME)](https://standards.cipp.app/standards/omebranding) standard. [Read more](https://learn.microsoft.com/en-us/purview/set-up-new-message-encryption-capabilities)", + "docsDescription": "Sets AzureRMSLicensingEnabled to true, the prerequisite for Microsoft Purview Message Encryption, and SimplifiedClientAccessEnabled to true so the Encrypt button appears when composing mail in Outlook on the web and the new Outlook. Reports the IRM licensing state per tenant, including the licensing location, so you can see at a glance which tenants have message encryption available. Remediation is deliberately skipped for tenants with an on-premises AD RMS licensing location, as Purview Message Encryption is not compatible with AD RMS and those tenants need to be migrated to Azure RMS first.", "executiveText": "Turns on the built-in encryption that lets staff send protected email to anyone, including recipients outside the organization. Uses licensing the organization already owns, removing the need for a separate secure-email product.", "addedComponent": [], "label": "Enable Purview Message Encryption", "impact": "Low Impact", "impactColour": "info", "addedDate": "2026-08-04", - "powershellEquivalent": "Set-IRMConfiguration -AzureRMSLicensingEnabled $true", + "powershellEquivalent": "Set-IRMConfiguration -AzureRMSLicensingEnabled $true -SimplifiedClientAccessEnabled $true", "recommendedBy": [], "requiredCapabilities": [ "EXCHANGE_S_STANDARD", diff --git a/src/layouts/HeaderedTabbedLayout.jsx b/src/layouts/HeaderedTabbedLayout.jsx index 9ab87cf40918..94ef13bcfa42 100644 --- a/src/layouts/HeaderedTabbedLayout.jsx +++ b/src/layouts/HeaderedTabbedLayout.jsx @@ -21,6 +21,7 @@ import { useActionsDispatch } from "../hooks/use-actions-dispatch"; import { TabNavigationContext, useTabNavigationValue } from "./tab-navigation-context"; import { CippPageActionsFab } from "../components/CippComponents/CippPageActionsFab"; import { CippTabPicker } from "../components/CippComponents/CippTabPicker"; +import { ApiGetCall } from "../api/ApiCall"; export const HeaderedTabbedLayout = (props) => { const { @@ -62,7 +63,30 @@ export const HeaderedTabbedLayout = (props) => { const handleTabsChange = useCallback((event, value) => navigateToTab(value), [navigateToTab]); - const currentTab = tabOptions.find((option) => option.path === pathname); + // Feature-flag gating, same rules as TabbedLayout: a DISABLED flag hides its Pages; + // an ENABLED flag hides its HidesPages (the pages it replaces - e.g. Baselines + // supersedes the classic Standards and Drift tabs on Manage Tenant). + const featureFlags = ApiGetCall({ + url: "/api/ListFeatureFlags", + queryKey: "featureFlags", + staleTime: 600000, + }); + const visibleTabs = useMemo(() => { + if (!featureFlags.isSuccess || !Array.isArray(featureFlags.data)) return tabOptions; + const disabledPages = featureFlags.data + .filter((flag) => flag.Enabled === false || flag.enabled === false) + .flatMap((flag) => flag.Pages || flag.pages || []) + .filter((page) => typeof page === "string"); + const replacedPages = featureFlags.data + .filter((flag) => flag.Enabled === true || flag.enabled === true) + .flatMap((flag) => flag.HidesPages || flag.hidesPages || []) + .filter((page) => typeof page === "string"); + const hiddenPages = [...disabledPages, ...replacedPages]; + if (hiddenPages.length === 0) return tabOptions; + return tabOptions.filter((option) => !hiddenPages.includes(option.path)); + }, [tabOptions, featureFlags.isSuccess, featureFlags.data]); + + const currentTab = visibleTabs.find((option) => option.path === pathname); // Below md the tab row scrolls horizontally and still hides tabs off the right edge, so // navigation collapses to a picker in the title row — the one part of that row that is @@ -88,7 +112,7 @@ export const HeaderedTabbedLayout = (props) => { ); const tabNavValue = useTabNavigationValue({ - tabs: tabOptions, + tabs: visibleTabs, currentPath: pathname, onNavigate: navigateToTab, actions: sheetActions, @@ -227,7 +251,7 @@ export const HeaderedTabbedLayout = (props) => { }, }} > - {tabOptions.map((option) => { + {visibleTabs.map((option) => { const icon = getIconByName(option.icon, { fontSize: "small" }); const iconPosition = option.iconPosition ?? "start"; const compactIcon = icon && ["end", "start"].includes(iconPosition); diff --git a/src/layouts/TabbedLayout.jsx b/src/layouts/TabbedLayout.jsx index cc89fd11a794..2ac692e10ce9 100644 --- a/src/layouts/TabbedLayout.jsx +++ b/src/layouts/TabbedLayout.jsx @@ -30,14 +30,21 @@ export const TabbedLayout = (props) => { if (!featureFlags.isSuccess || !Array.isArray(featureFlags.data)) return tabs + // A DISABLED flag hides its Pages; an ENABLED flag hides its HidesPages (the + // pages it replaces - e.g. Baselines supersedes the classic Standards tabs). const disabledPages = featureFlags.data .filter((flag) => flag.Enabled === false || flag.enabled === false) .flatMap((flag) => flag.Pages || flag.pages || []) .filter((page) => typeof page === 'string') + const replacedPages = featureFlags.data + .filter((flag) => flag.Enabled === true || flag.enabled === true) + .flatMap((flag) => flag.HidesPages || flag.hidesPages || []) + .filter((page) => typeof page === 'string') + const hiddenPages = [...disabledPages, ...replacedPages] - if (disabledPages.length === 0) return tabs + if (hiddenPages.length === 0) return tabs - return tabs.filter((option) => !disabledPages.includes(option.path)) + return tabs.filter((option) => !hiddenPages.includes(option.path)) }, [tabOptions, featureFlags.isSuccess, featureFlags.data, showAdvanced]) const navigateToTab = useCallback( diff --git a/src/layouts/config.js b/src/layouts/config.js index 8dab949ae483..c54b951f3ec4 100644 --- a/src/layouts/config.js +++ b/src/layouts/config.js @@ -216,42 +216,32 @@ export const nativeMenuItems = [ permissions: ['Tenant.Relationship.*'], scope: 'global', }, + // Flag-gated swap: the Baselines feature flag lists this path in its Pages + // (hidden while the flag is off) and the classic Standards/Drift paths in + // HidesPages (hidden while it is on) - the two never show together. + { + title: 'Baselines', + path: '/tenant/baselines', + permissions: ['Tenant.Baselines.*'], + scope: 'global', + }, + { + title: 'Domains Analyser', + path: '/tenant/standards/domains-analyser', + permissions: ['Tenant.DomainAnalyser.*'], + scope: 'global', + }, { title: 'Standards & Drift', - permissions: [ - 'Tenant.Standards.*', - 'Tenant.Baselines.*', - 'Tenant.BestPracticeAnalyser.*', - 'Tenant.DomainAnalyser.*', - ], - items: [ - { - title: 'Standards Management', - path: '/tenant/standards/alignment', - permissions: ['Tenant.Standards.*'], - scope: 'global', - }, - // Baselines - hidden from the nav for now; reach it directly - // at /tenant/baselines - // { - // title: 'Baselines (Preview)', - // path: '/tenant/baselines', - // permissions: ['Tenant.Baselines.*'], - // scope: 'global', - // }, - { - title: 'Best Practice Analyser', - path: '/tenant/standards/bpa-report', - permissions: ['Tenant.BestPracticeAnalyser.*'], - scope: 'global', - }, - { - title: 'Domains Analyser', - path: '/tenant/standards/domains-analyser', - permissions: ['Tenant.DomainAnalyser.*'], - scope: 'global', - }, - ], + path: '/tenant/standards/alignment', + permissions: ['Tenant.Standards.*'], + scope: 'global', + }, + { + title: 'Best Practice Analyser', + path: '/tenant/standards/bpa-report', + permissions: ['Tenant.BestPracticeAnalyser.*'], + scope: 'global', }, { title: 'Conditional Access', diff --git a/src/layouts/index.js b/src/layouts/index.js index 44c33201f056..db2db462a98a 100644 --- a/src/layouts/index.js +++ b/src/layouts/index.js @@ -123,20 +123,28 @@ export const Layout = (props) => { return } - // Get disabled pages from feature flags - only filter if we have valid data - let disabledPages = [] + // Get hidden pages from feature flags - only filter if we have valid data. + // A DISABLED flag hides its Pages (features gated behind the flag); an ENABLED + // flag hides its HidesPages (features it replaces - e.g. Baselines supersedes + // the classic Standards and Drift pages). + let hiddenPages = [] if (featureFlags.isSuccess && Array.isArray(featureFlags.data)) { - disabledPages = featureFlags.data + const disabledPages = featureFlags.data .filter((flag) => flag.Enabled === false || flag.enabled === false) .flatMap((flag) => flag.Pages || flag.pages || []) .filter((page) => typeof page === 'string') + const replacedPages = featureFlags.data + .filter((flag) => flag.Enabled === true || flag.enabled === true) + .flatMap((flag) => flag.HidesPages || flag.hidesPages || []) + .filter((page) => typeof page === 'string') + hiddenPages = [...disabledPages, ...replacedPages] } const filterItemsByRole = (items) => { return items .map((item) => { - // Check if page is disabled by feature flag - if (item.path && disabledPages.length > 0 && disabledPages.includes(item.path)) { + // Check if page is hidden by feature flag + if (item.path && hiddenPages.length > 0 && hiddenPages.includes(item.path)) { return null } diff --git a/src/pages/email/tools/message-encryption/index.js b/src/pages/email/tools/message-encryption/index.js index b32058bf0c82..7fc7c1641d9b 100644 --- a/src/pages/email/tools/message-encryption/index.js +++ b/src/pages/email/tools/message-encryption/index.js @@ -34,6 +34,7 @@ const Page = () => { // pending value against B's tenantFilter, and A's test output would read as B's. formControl.reset({ AzureRMSLicensingEnabled: false, + SimplifiedClientAccessEnabled: false, Sender: '', Recipient: '', }) @@ -46,6 +47,7 @@ const Page = () => { formControl.reset({ ...formControl.getValues(), AzureRMSLicensingEnabled: !!irm?.AzureRMSLicensingEnabled, + SimplifiedClientAccessEnabled: !!irm?.SimplifiedClientAccessEnabled, }) } }, [irmRequest.isSuccess, irm]) @@ -78,7 +80,7 @@ const Page = () => { value: yesNo(irm?.ExternalLicensingEnabled), }, { - label: 'Protect Button in Outlook on the Web', + label: 'Encrypt Button in Outlook', value: yesNo(irm?.SimplifiedClientAccessEnabled), }, { @@ -110,6 +112,7 @@ const Page = () => { tenantFilter: tenant, Action: 'Set', AzureRMSLicensingEnabled: !!values?.AzureRMSLicensingEnabled, + SimplifiedClientAccessEnabled: !!values?.SimplifiedClientAccessEnabled, })} addedButtons={ {pageTitle} - + { required={false} disableClearable={false} /> + + + With scheduled runs disabled, this baseline only executes + when you run it yourself - drift is not detected or + remediated in between. + @@ -1118,6 +1166,7 @@ const Page = () => { catalogByName={catalogByName} registerSerializer={registerSerializer} variableOptions={variableOptions} + groupOptions={groupOptions} /> ))} diff --git a/src/pages/tenant/baselines/templates/index.js b/src/pages/tenant/baselines/templates/index.js index 2c28006c27f1..4eeda181f51d 100644 --- a/src/pages/tenant/baselines/templates/index.js +++ b/src/pages/tenant/baselines/templates/index.js @@ -1,11 +1,19 @@ import { + Alert, Box, Button, + Checkbox, Chip, + CircularProgress, Divider, + FormControlLabel, LinearProgress, + List, + ListItem, + ListItemText, Stack, SvgIcon, + Switch, Typography, } from '@mui/material' import Link from 'next/link' @@ -18,6 +26,7 @@ import { Edit, GitHub, PlayArrow, + Upgrade, } from '@mui/icons-material' import { Layout as DashboardLayout } from '../../../../layouts/index.js' import { TabbedLayout } from '../../../../layouts/TabbedLayout' @@ -28,7 +37,8 @@ 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' +import { ApiGetCall, ApiPostCall } from '../../../../api/ApiCall' +import { CippApiResults } from '../../../../components/CippComponents/CippApiResults' // The API serializes single-element arrays as a bare object; the selector needs a real array. const asOptionArray = (value) => @@ -39,10 +49,52 @@ const asOptionArray = (value) => const Page = () => { const pageTitle = 'Baselines' const [catalogVisible, setCatalogVisible] = useState(false) + const [migrateVisible, setMigrateVisible] = useState(false) + const [migrateSelected, setMigrateSelected] = useState([]) + const [migrateReportOnly, setMigrateReportOnly] = useState(true) + const [migrateAddDetect, setMigrateAddDetect] = useState(false) const integrations = ApiGetCall({ url: '/api/ListExtensionsConfig', queryKey: 'Integrations', }) + const migratePreview = ApiPostCall({ + onResult: (result) => { + // Pre-select everything migratable; skipped/up-to-date rows stay untouched. + setMigrateSelected( + (result?.Metadata?.templates ?? []) + .filter((template) => + ['Ready', 'WillUpdate'].includes(template.status) + ) + .map((template) => template.v2Guid) + ) + }, + }) + const migrateCommit = ApiPostCall({ + relatedQueryKeys: ['ListBaseline*'], + }) + const openMigrate = () => { + setMigrateVisible(true) + migratePreview.mutate({ + url: '/api/ExecBaselineMigrate', + data: { action: 'preview' }, + }) + } + // A finished commit replaces the preview as the list's source, so each row shows + // what actually happened to it. + const migrationReport = + migrateCommit.data?.data?.Metadata ?? migratePreview.data?.data?.Metadata + const migrationTemplates = Array.isArray(migrationReport?.templates) + ? migrationReport.templates + : [] + const migrateStatusChip = { + Ready: { color: 'info', label: 'Ready' }, + WillUpdate: { color: 'info', label: 'Will update' }, + Migrated: { color: 'success', label: 'Migrated' }, + Updated: { color: 'success', label: 'Updated' }, + UpToDate: { color: 'default', label: 'Up to date' }, + Skipped: { color: 'default', label: 'Skipped' }, + Failed: { color: 'error', label: 'Failed' }, + } const actions = [ { @@ -155,6 +207,10 @@ const Page = () => { { label: 'Description', value: row.description }, { label: 'Standards', value: row.standardsCount }, { label: 'Remediation', value: row.remediationPosture }, + { + label: 'Scheduled Runs', + value: row.disableScheduledRuns ? 'Disabled' : 'Enabled', + }, { label: 'Last Updated', value: row.updatedAt @@ -332,6 +388,203 @@ const Page = () => { > Browse Catalog + + setMigrateVisible(false)} + size="lg" + footer={ + + + + + } + > + + + Converts your classic Standards templates (including drift + templates) into baselines. The originals are never modified, + but while the Baselines feature is enabled the classic + Standards and Drift pages and their scheduled runs are turned + off - only one engine manages your tenants at a time. + + + setMigrateReportOnly(event.target.checked) + } + /> + } + label="Import everything as report-only (recommended) - re-enable auto-remediation per standard once you have reviewed the results" + /> + + setMigrateAddDetect(event.target.checked) + } + /> + } + label="Migrated drift templates should also alert on Intune and Conditional Access policies that were not created from a template" + /> + + {migratePreview.isPending && ( + + + + )} + {!migratePreview.isPending && migrationTemplates.length === 0 && ( + + No classic Standards templates were found to migrate. + + )} + + {migrationTemplates.map((template) => { + const selectable = ['Ready', 'WillUpdate'].includes( + template.status + ) + const chip = + migrateStatusChip[template.status] ?? + migrateStatusChip.Ready + return ( + + + setMigrateSelected((prev) => + prev.includes(template.v2Guid) + ? prev.filter((id) => id !== template.v2Guid) + : [...prev, template.v2Guid] + ) + } + sx={{ mt: 0.5 }} + /> + + + {template.templateName || '(unnamed template)'} + + {template.type === 'drift' && ( + + )} + + + {template.standardsCount} standard + {template.standardsCount === 1 ? '' : 's'} + + + } + secondary={ + + {(template.tenants ?? []).length > 0 && ( + + Tenants: {(template.tenants ?? []).join(', ')} + + )} + {template.detail && ( + + {template.detail} + + )} + {(template.warnings ?? []).map((warning) => ( + + {warning} + + ))} + + } + /> + + ) + })} + + + { } return ( - <> - } onClick={createDialog.handleOpen} sx={{ mr: 1 }}> - Add Template - - } - /> + + + + } onClick={createDialog.handleOpen} sx={{ mr: 1 }}> + Add Template + + } + /> + { ...templateFields, ]} /> - + ) } -Page.getLayout = (page) => ( - - {page} - -) +Page.getLayout = (page) => {page} export default Page diff --git a/src/pages/tenant/standards/templates/template.jsx b/src/pages/tenant/standards/templates/template.jsx index 7e442863b3f1..95c1398f260a 100644 --- a/src/pages/tenant/standards/templates/template.jsx +++ b/src/pages/tenant/standards/templates/template.jsx @@ -367,10 +367,10 @@ const Page = () => { @@ -382,7 +382,12 @@ const Page = () => { ? 'Add Drift Template' : 'Add Standards Template'} - + + { + const formControl = useForm({ mode: 'onBlur' }) + const [expanded, setExpanded] = React.useState(startExpanded) + return ( + setExpanded((prev) => !prev)} + onRemove={() => {}} + /> + ) +} + +// MUI's filled TextField in this tree doesn't associate label->input the way +// getByLabelText needs, so assertions read the inputs and map to their labels. +const fieldValues = () => { + const byLabel = {} + for (const input of screen.queryAllByRole('textbox')) { + const label = input.closest('.MuiFormControl-root')?.querySelector('label')?.textContent + if (label) byLabel[label] = input.value + } + return byLabel +} + +const expectSeeded = () => { + const values = fieldValues() + expect(values['Security contact email']).toBe('support@bezalu.com') + expect(values['Technical contact email']).toBe('support@bezalu.com') + expect(values['General/privacy contact email']).toBe('support@bezalu.com') + expect(values['Marketing contact email']).toBe('') +} + +describe('CippBaselineStandardItem saved-variable seeding', () => { + it('seeds the settings fields from savedConfig.variables when mounted expanded', async () => { + renderWithProviders() + await waitFor(expectSeeded) + }) + + it('seeds the settings fields when expanded LATER (details mount lazily)', async () => { + const { default: userEvent } = await import('@testing-library/user-event') + const user = userEvent.setup() + renderWithProviders() + await user.click(screen.getByText('Set contact e-mails')) + await waitFor(expectSeeded) + }) +}) diff --git a/tests/components/CippComponents/CippAddUserDrawer.test.jsx b/tests/components/CippComponents/CippAddUserDrawer.test.jsx index f77592a792cd..29b6fca9dd6e 100644 --- a/tests/components/CippComponents/CippAddUserDrawer.test.jsx +++ b/tests/components/CippComponents/CippAddUserDrawer.test.jsx @@ -54,6 +54,31 @@ vi.mock('../../../src/components/CippComponents/CippOffCanvas', () => ({ const idleGet = { isSuccess: false, isFetching: false, isError: false, data: undefined, refetch: vi.fn() } const okGet = (data) => ({ isSuccess: true, isFetching: false, isError: false, data, refetch: vi.fn() }) +// built once: CippAutoComplete's option mapping keys on data identity, a fresh literal per call never settles +const userDefaults = okGet([]) +const extensionsConfig = okGet({}) +const groups = okGet([]) +const customDataMappings = okGet({ Results: [] }) +const userGroups = okGet([]) +const tenantDomains = { + isSuccess: true, + isFetching: false, + isError: false, + data: { + pages: [ + { + Results: [ + { id: 'testdomain.com', isDefault: true, isInitial: false, isVerified: true }, + { id: 'other.com', isDefault: false, isInitial: false, isVerified: true }, + ], + }, + ], + }, + fetchNextPage: vi.fn(), + refetch: vi.fn(), +} +const idlePaginated = { ...idleGet, fetchNextPage: vi.fn() } + // Mutable state backing the ApiPostCall mock: flipping it and re-rendering imitates the // react-query mutation lifecycle (idle -> pending -> success) the drawer sees in production. let postState @@ -61,35 +86,16 @@ let mutateSpy function mockApis() { ApiGetCall.mockImplementation(({ url }) => { - if (url.startsWith('/api/ListNewUserDefaults')) return okGet([]) - if (url.startsWith('/api/ListExtensionsConfig')) return okGet({}) - if (url.startsWith('/api/ListGroups')) return okGet([]) - if (url.startsWith('/api/ListCustomDataMappings')) return okGet({ Results: [] }) - if (url.startsWith('/api/ListUserGroups')) return okGet([]) + if (url.startsWith('/api/ListNewUserDefaults')) return userDefaults + if (url.startsWith('/api/ListExtensionsConfig')) return extensionsConfig + if (url.startsWith('/api/ListGroups')) return groups + if (url.startsWith('/api/ListCustomDataMappings')) return customDataMappings + if (url.startsWith('/api/ListUserGroups')) return userGroups return idleGet }) - ApiGetCallWithPagination.mockImplementation(({ url }) => { - if (url === '/api/ListGraphRequest') { - return { - isSuccess: true, - isFetching: false, - isError: false, - data: { - pages: [ - { - Results: [ - { id: 'testdomain.com', isDefault: true, isInitial: false, isVerified: true }, - { id: 'other.com', isDefault: false, isInitial: false, isVerified: true }, - ], - }, - ], - }, - fetchNextPage: vi.fn(), - refetch: vi.fn(), - } - } - return { ...idleGet, fetchNextPage: vi.fn() } - }) + ApiGetCallWithPagination.mockImplementation(({ url }) => + url === '/api/ListGraphRequest' ? tenantDomains : idlePaginated + ) ApiPostCall.mockImplementation(() => ({ ...postState, mutate: mutateSpy })) } @@ -193,5 +199,6 @@ describe('CippAddUserDrawer - create another user without a page refresh (issue username: 'second.user', primDomain: { value: 'testdomain.com' }, }) - }) + // two full form fills through userEvent.type + }, 15000) }) diff --git a/tests/components/CippSettings/CippContainerManagement.test.jsx b/tests/components/CippSettings/CippContainerManagement.test.jsx index 4a2527c37610..f2cbdebcee27 100644 --- a/tests/components/CippSettings/CippContainerManagement.test.jsx +++ b/tests/components/CippSettings/CippContainerManagement.test.jsx @@ -9,7 +9,10 @@ vi.mock('../../../src/api/ApiCall', async () => (await import('../../mocks/api-c import { api, getResult, paginatedResult, postResult } from '../../mocks/api-call' // stable references, fresh literals per call spin CippAutoComplete's mapping effect -api.get = getResult() +const statusGet = getResult() +// status payload only answers its own url, the page's other GETs stay idle +const idleGet = getResult({ isSuccess: false }) +api.get = (opts) => (opts.url === '/api/ExecContainerManagement' ? statusGet : idleGet) api.paginated = paginatedResult() api.post = postResult() @@ -55,26 +58,26 @@ const ALERT_RE = /unsupported build from an unmerged branch/ describe('CippContainerManagement branch-build flagging', () => { beforeEach(() => { vi.clearAllMocks() - api.get.data = undefined + statusGet.data = undefined api.paginated.data = { pages: [{ Results: [] }] } }) it('running pinned branch build chips the split tag, not Unknown', () => { - api.get.data = { Results: statusResults('fix-sso-thing-a1b2c3d') } + statusGet.data = { Results: statusResults('fix-sso-thing-a1b2c3d') } renderWithProviders() expect(screen.getByText(PINNED_PRETTY)).toBeInTheDocument() expect(screen.queryByText('Unknown')).not.toBeInTheDocument() }) it('running branch build shows the unsupported-build alert and seeds the picker with its tag', async () => { - api.get.data = { Results: statusResults('feat-new-widget') } + statusGet.data = { Results: statusResults('feat-new-widget') } renderWithProviders() expect(await screen.findByText(ALERT_RE)).toBeInTheDocument() expect(screen.getByRole('combobox', { name: 'Release Channel' })).toHaveValue('feat-new-widget') }) it('standard channel chips its friendly label and raises no branch alert', async () => { - api.get.data = { Results: statusResults('dev') } + statusGet.data = { Results: statusResults('dev') } renderWithProviders() expect(screen.getByText('Dev')).toBeInTheDocument() // wait for the seed effect so the alert-absence check runs against the settled form @@ -86,7 +89,7 @@ describe('CippContainerManagement branch-build flagging', () => { it('unrecognized non-branch tag chips Unknown without raising the branch alert', async () => { // bare version tag: not a valid channel, does not match BuildChannelPattern - api.get.data = { Results: statusResults('8.0.1') } + statusGet.data = { Results: statusResults('8.0.1') } renderWithProviders() expect(screen.getByText('Unknown')).toBeInTheDocument() await waitFor(() => { @@ -96,7 +99,7 @@ describe('CippContainerManagement branch-build flagging', () => { }) it('picking a branch build raises the alert, switching back to a standard channel clears it', async () => { - api.get.data = { Results: statusResults('latest') } + statusGet.data = { Results: statusResults('latest') } api.paginated.data = { pages: [{ Results: channelListResults }] } const user = userEvent.setup() renderWithProviders() diff --git a/tests/components/CippTable/CIPPTableToptoolbar.test.jsx b/tests/components/CippTable/CIPPTableToptoolbar.test.jsx index bfa93e89bb11..8c80d6516a45 100644 --- a/tests/components/CippTable/CIPPTableToptoolbar.test.jsx +++ b/tests/components/CippTable/CIPPTableToptoolbar.test.jsx @@ -220,14 +220,15 @@ describe('CIPPTableToptoolbar - preset list refresh', () => { }) }, 30000) + // pageName '' (jsdom router is '/') means no persistence, the slot tests name their key it('restores both persisted slots and discards garbage global values', async () => { - renderGraphTable({}, { + renderGraphTable({ persistenceKey: 'SlotsTest' }, { settings: settingsWith({ persistFilters: true, setLastUsedFilter: vi.fn(), lastUsedFilters: { // legacy single-slot shape with a non-string global value - '': { type: 'global', value: [{ id: 'department', value: 'IT' }], name: 'Legacy Garbage' }, + SlotsTest: { type: 'global', value: [{ id: 'department', value: 'IT' }], name: 'Legacy Garbage' }, }, }), }) @@ -241,13 +242,13 @@ describe('CIPPTableToptoolbar - preset list refresh', () => { }) it('restores both persisted slots and discards new-shape garbage global values', async () => { - renderGraphTable({}, { + renderGraphTable({ persistenceKey: 'SlotsTest' }, { settings: settingsWith({ persistFilters: true, setLastUsedFilter: vi.fn(), lastUsedFilters: { // new shape can carry the same non-string global garbage the legacy branch discards - '': { + SlotsTest: { graph: null, table: { id: 'Garbage', name: 'Garbage', type: 'global', value: [{ id: 'department', value: 'IT' }] }, }, @@ -264,12 +265,12 @@ describe('CIPPTableToptoolbar - preset list refresh', () => { }) it('restores a legacy column filter into the table slot', async () => { - renderGraphTable({}, { + renderGraphTable({ persistenceKey: 'SlotsTest' }, { settings: settingsWith({ persistFilters: true, setLastUsedFilter: vi.fn(), lastUsedFilters: { - '': { type: 'column', value: [{ id: 'department', value: 'IT' }], name: 'IT only' }, + SlotsTest: { type: 'column', value: [{ id: 'department', value: 'IT' }], name: 'IT only' }, }, }), }) @@ -284,12 +285,12 @@ describe('CIPPTableToptoolbar - preset list refresh', () => { // overwriting whatever the user had just applied with the persisted filter. it('does not clobber a user filter applied after the persisted one was restored', async () => { const user = userEvent.setup() - renderGraphTable({}, { + renderGraphTable({ persistenceKey: 'SlotsTest' }, { settings: settingsWith({ persistFilters: true, setLastUsedFilter: vi.fn(), lastUsedFilters: { - '': { type: 'column', value: [{ id: 'department', value: 'IT' }], name: 'IT only' }, + SlotsTest: { type: 'column', value: [{ id: 'department', value: 'IT' }], name: 'IT only' }, }, }), }) diff --git a/tests/components/CippTable/CippDataTable.stories.jsx b/tests/components/CippTable/CippDataTable.stories.jsx index eed8a4c817f8..5c35b7d67eba 100644 --- a/tests/components/CippTable/CippDataTable.stories.jsx +++ b/tests/components/CippTable/CippDataTable.stories.jsx @@ -480,3 +480,40 @@ export const GraphBackedEditFilters = { }) }, } + +// cached report column (membersCsv) wears the subTable header, no nested-table button. MRT renders no header cells in jsdom +export const CachedReportColumns = { + args: { + title: 'Groups', + data: [{ id: 'parent-1', displayName: 'Finance', membersCsv: 'Jane, Bob' }], + simpleColumns: ['displayName', 'members'], + subTables: [ + { + id: 'members', + header: 'Members', + label: 'View members', + cachedColumn: 'membersCsv', + table: { + title: 'Members of [displayName]', + api: { url: '/api/TestRelated', dataKey: 'Results' }, + simpleColumns: ['displayName'], + }, + }, + ], + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step('cached csv column takes the subTable header', async () => { + await waitFor(() => { + expect(canvas.getByRole('columnheader', { name: /Members/ })).toBeVisible() + }) + expect(canvas.queryByRole('columnheader', { name: /csv/i })).toBeNull() + }) + + await step('cell shows the cached value, no nested table button', async () => { + await expect(canvas.getByText('Jane, Bob')).toBeVisible() + expect(canvas.queryByRole('button', { name: 'View members' })).toBeNull() + }) + }, +} diff --git a/tests/components/CippTable/CippDataTable.test.jsx b/tests/components/CippTable/CippDataTable.test.jsx index 2b7c1dd21658..20173be8bb03 100644 --- a/tests/components/CippTable/CippDataTable.test.jsx +++ b/tests/components/CippTable/CippDataTable.test.jsx @@ -6,6 +6,15 @@ import { renderWithProviders } from '../../test-utils' import { CippDataTable } from '../../../src/components/CippTable/CippDataTable' import { resetOverlayHistory } from '../../../src/utils/overlay-history' +vi.mock('../../../src/api/ApiCall', async () => (await import('../../mocks/api-call')).apiCallMock()) +import { api, paginatedResult } from '../../mocks/api-call' + +// idle keeps static-data tables on their data prop; the nested result is re-wrapped per call like react-query's tracked copy, the memo'd toolbar needs it to see selection +const nestedRows = [{ id: 'child-1', displayName: 'Jane Doe' }] +const nestedResult = paginatedResult(nestedRows) +const idlePaginated = paginatedResult([], { isSuccess: false }) +api.paginated = (opts) => (opts?.url === '/api/TestRelated' ? { ...nestedResult } : idlePaginated) + const basicData = [ { displayName: 'Alice Smith', mail: 'alice@contoso.com', department: 'IT', accountEnabled: true }, { displayName: 'Bob Johnson', mail: 'bob@contoso.com', department: 'Sales', accountEnabled: true }, @@ -718,7 +727,13 @@ describe('CippDataTable cards->table toggle scroll', () => { describe('CippDataTable subTables', () => { const parentRows = [{ id: 'parent-1', displayName: 'Finance' }] - const relatedRows = [{ id: 'child-1', displayName: 'Jane Doe' }] + // live nested table, the shape groups/index.js ships + const nestedTable = { + title: 'Related for [displayName]', + api: { url: '/api/TestRelated', dataKey: 'Results' }, + simpleColumns: ['displayName'], + viewMode: 'cards', + } it('injects a button column that opens a nested table', async () => { const user = userEvent.setup() @@ -733,12 +748,7 @@ describe('CippDataTable subTables', () => { id: 'related', header: 'Related', label: 'View', - table: { - title: 'Related for [displayName]', - data: relatedRows, - simpleColumns: ['displayName'], - viewMode: 'cards', - }, + table: nestedTable, }, ]} /> @@ -771,10 +781,7 @@ describe('CippDataTable subTables', () => { header: 'Related', label: 'View', table: { - title: 'Related for [displayName]', - data: relatedRows, - simpleColumns: ['displayName'], - viewMode: 'cards', + ...nestedTable, actions: [ { label: 'Remove', @@ -797,15 +804,18 @@ describe('CippDataTable subTables', () => { await user.click(within(dialog).getByRole('button', { name: 'Row actions' })) await user.click(await screen.findByText('Remove')) - expect(rowFn).toHaveBeenCalledWith( - expect.objectContaining({ - id: 'child-1', - displayName: 'Jane Doe', - parent: expect.objectContaining({ id: 'parent-1', displayName: 'Finance' }), - }), - expect.anything(), - expect.anything() - ) + // the row sheet hands the action off to its exit transition + await waitFor(() => { + expect(rowFn).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'child-1', + displayName: 'Jane Doe', + parent: expect.objectContaining({ id: 'parent-1', displayName: 'Finance' }), + }), + expect.anything(), + expect.anything() + ) + }) rowFn.mockClear() await user.click(within(dialog).getByRole('button', { name: 'Select' })) @@ -813,14 +823,16 @@ describe('CippDataTable subTables', () => { await user.click(within(dialog).getByRole('button', { name: 'Actions' })) await user.click(await screen.findByText('Remove')) - expect(rowFn).toHaveBeenCalledWith( - expect.objectContaining({ - id: 'child-1', - parent: expect.objectContaining({ id: 'parent-1' }), - }), - expect.anything(), - expect.anything() - ) + await waitFor(() => { + expect(rowFn).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'child-1', + parent: expect.objectContaining({ id: 'parent-1' }), + }), + expect.anything(), + expect.anything() + ) + }) }) it('replaces a data column that shares the subTable id', async () => { @@ -836,12 +848,7 @@ describe('CippDataTable subTables', () => { id: 'related', header: 'Related', label: 'View', - table: { - title: 'Related for [displayName]', - data: relatedRows, - simpleColumns: ['displayName'], - viewMode: 'cards', - }, + table: nestedTable, }, ]} /> @@ -869,12 +876,7 @@ describe('CippDataTable subTables', () => { id: 'related', header: 'Related', label: 'View', - table: { - title: 'Related for [displayName]', - data: relatedRows, - simpleColumns: ['displayName'], - viewMode: 'cards', - }, + table: nestedTable, }, ]} /> @@ -897,12 +899,7 @@ describe('CippDataTable subTables', () => { header: 'Members', label: 'View members', cachedColumn: 'membersCsv', - table: { - title: 'Members of [displayName]', - data: relatedRows, - simpleColumns: ['displayName'], - viewMode: 'cards', - }, + table: { ...nestedTable, title: 'Members of [displayName]' }, }, ]} /> @@ -913,34 +910,6 @@ describe('CippDataTable subTables', () => { expect(screen.getByText('Jane, Bob')).toBeInTheDocument() }) - it('renders cached report columns in table view without a stale column order crash', async () => { - renderWithProviders( - - ) - - await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument()) - expect(screen.getByRole('columnheader', { name: 'Members' })).toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'View members' })).not.toBeInTheDocument() - }) - it('still shows the nested table button when cachedColumn is configured but missing from the data', async () => { renderWithProviders( { header: 'Members', label: 'View members', cachedColumn: 'membersCsv', - table: { - title: 'Members of [displayName]', - data: relatedRows, - simpleColumns: ['displayName'], - viewMode: 'cards', - }, + table: { ...nestedTable, title: 'Members of [displayName]' }, }, ]} /> @@ -972,7 +936,7 @@ describe('CippDataTable subTables', () => { it('shows the nested table button when cachedColumn exists but is empty (live API shape)', async () => { renderWithProviders( { header: 'Members', label: 'View members', cachedColumn: 'membersCsv', - table: { - title: 'Members of [displayName]', - data: relatedRows, - simpleColumns: ['displayName'], - }, + table: { ...nestedTable, title: 'Members of [displayName]' }, }, ]} /> @@ -1010,10 +970,9 @@ describe('CippDataTable subTables', () => { header: 'Related', label: 'View', table: { - title: 'Related for [displayName]', - data: relatedRows, - simpleColumns: ['displayName'], - viewMode: 'cards', + ...nestedTable, + // table view, the card header is the only cardButton slot inside a dialog + viewMode: 'table', cardButton: { label: 'Add Members', url: '/api/EditGroup', diff --git a/tests/components/CippWizard/CippWizardAutopilotTypeSelection.stories.jsx b/tests/components/CippWizard/CippWizardAutopilotTypeSelection.stories.jsx new file mode 100644 index 000000000000..ff5d2dfdb6f1 --- /dev/null +++ b/tests/components/CippWizard/CippWizardAutopilotTypeSelection.stories.jsx @@ -0,0 +1,30 @@ +import React from 'react' +import { useForm } from 'react-hook-form' +import { CippWizardAutopilotTypeSelection } from '../../../src/components/CippWizard/CippWizardAutopilotTypeSelection' + +// Mirrors the add-device wizard's initialState: autopilot is preselected so the +// user can click Next without touching the step. +const Harness = () => { + const formControl = useForm({ + mode: 'onChange', + defaultValues: { deploymentType: 'autopilot' }, + }) + return ( + {}} + onPreviousStep={() => {}} + /> + ) +} + +export default { + title: 'Components/CippWizard/CippWizardAutopilotTypeSelection', + component: CippWizardAutopilotTypeSelection, +} + +export const Default = { + render: () => , +} diff --git a/tests/components/CippWizard/CippWizardAutopilotTypeSelection.test.jsx b/tests/components/CippWizard/CippWizardAutopilotTypeSelection.test.jsx new file mode 100644 index 000000000000..9a7882c19d5e --- /dev/null +++ b/tests/components/CippWizard/CippWizardAutopilotTypeSelection.test.jsx @@ -0,0 +1,75 @@ +import React from 'react' +import { describe, it, expect } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useForm } from 'react-hook-form' +import { renderWithProviders } from '../../test-utils' +import { CippWizardAutopilotTypeSelection } from '../../../src/components/CippWizard/CippWizardAutopilotTypeSelection' + +const Harness = ({ onForm, defaultValues }) => { + const formControl = useForm({ + mode: 'onChange', + // Mirrors the add-device wizard's initialState + defaultValues: { deploymentType: 'autopilot', ...defaultValues }, + }) + onForm?.(formControl) + return ( + {}} + onPreviousStep={() => {}} + /> + ) +} + +describe('CippWizardAutopilotTypeSelection', () => { + it('preselects autopilot so Next is enabled without a click', async () => { + let form + renderWithProviders( (form = f)} />) + + expect(form.getValues('deploymentType')).toBe('autopilot') + await waitFor(() => { + expect(screen.getByRole('button', { name: /next step/i })).toBeEnabled() + }) + }) + + it('switches to device prep and clears the autopilot fields', async () => { + const user = userEvent.setup() + let form + renderWithProviders( + (form = f)} + defaultValues={{ autopilotData: [{ SerialNumber: 'SN1' }], GroupName: 'group' }} + /> + ) + + await user.click(screen.getByText('Device Preparation (Corporate Identifiers)')) + + expect(form.getValues('deploymentType')).toBe('devicePrep') + expect(form.getValues('autopilotData')).toBeUndefined() + expect(form.getValues('GroupName')).toBeUndefined() + }) + + it('switches back to autopilot and clears the device prep fields', async () => { + const user = userEvent.setup() + let form + renderWithProviders( + (form = f)} + defaultValues={{ + deploymentType: 'devicePrep', + devicePrepData: [{ manufacturer: 'Dell', model: 'XPS', serialNumber: 'SN1' }], + overwriteExisting: true, + }} + /> + ) + + await user.click(screen.getByText('Windows Autopilot')) + + expect(form.getValues('deploymentType')).toBe('autopilot') + expect(form.getValues('devicePrepData')).toBeUndefined() + expect(form.getValues('overwriteExisting')).toBeUndefined() + }) +}) diff --git a/tests/components/CippWizard/CippWizardDevicePrepImport.stories.jsx b/tests/components/CippWizard/CippWizardDevicePrepImport.stories.jsx new file mode 100644 index 000000000000..daf9810b70b6 --- /dev/null +++ b/tests/components/CippWizard/CippWizardDevicePrepImport.stories.jsx @@ -0,0 +1,58 @@ +import React from 'react' +import { http, HttpResponse } from 'msw' +import { useForm } from 'react-hook-form' +import { CippWizardDevicePrepImport } from '../../../src/components/CippWizard/CippWizardDevicePrepImport' + +// The three the device prep wizard passes — a corporate identifier is exactly this triplet. +const fields = [ + { friendlyName: 'Manufacturer', propertyName: 'manufacturer' }, + { friendlyName: 'Model', propertyName: 'model' }, + { friendlyName: 'Serial Number', propertyName: 'serialNumber' }, +] + +const handlers = [ + http.get('*/api/ListGraphRequest', () => HttpResponse.json({ Results: [] })), + http.get('*/api/ListGraphExplorerPresets', () => HttpResponse.json({ Results: [] })), +] + +const Harness = ({ defaultValues }) => { + const formControl = useForm({ + mode: 'onChange', + defaultValues: { devicePrepData: [], ...defaultValues }, + }) + return ( + {}} + onPreviousStep={() => {}} + /> + ) +} + +export default { + title: 'Components/CippWizard/CippWizardDevicePrepImport', + component: CippWizardDevicePrepImport, + parameters: { msw: { handlers } }, +} + +export const Empty = { + render: () => , +} + +export const WithDevices = { + render: () => ( + + ), +} diff --git a/tests/components/CippWizard/CippWizardDevicePrepImport.test.jsx b/tests/components/CippWizard/CippWizardDevicePrepImport.test.jsx new file mode 100644 index 000000000000..bcb0b857a89e --- /dev/null +++ b/tests/components/CippWizard/CippWizardDevicePrepImport.test.jsx @@ -0,0 +1,124 @@ +import React from 'react' +import { describe, it, expect, vi } from 'vitest' +import { screen, within, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useForm } from 'react-hook-form' +import { renderWithProviders } from '../../test-utils' +import { CippWizardDevicePrepImport } from '../../../src/components/CippWizard/CippWizardDevicePrepImport' + +vi.mock('../../../src/hooks/use-breakpoint', async (importOriginal) => ({ + ...(await importOriginal()), + useIsMobileLayout: () => false, + useIsTabletLayout: () => false, + useTableViewMode: () => 'table', +})) + +vi.mock('../../../src/api/ApiCall', () => ({ + ApiGetCall: vi.fn(() => ({ data: undefined, isFetching: false, isSuccess: false })), + ApiPostCall: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), + ApiGetCallWithPagination: vi.fn(() => ({ data: undefined, isFetching: false })), +})) + +// The three the device prep wizard passes — a corporate identifier is exactly this triplet. +const fields = [ + { friendlyName: 'Manufacturer', propertyName: 'manufacturer' }, + { friendlyName: 'Model', propertyName: 'model' }, + { friendlyName: 'Serial Number', propertyName: 'serialNumber' }, +] + +const Harness = () => { + const formControl = useForm({ mode: 'onChange', defaultValues: { devicePrepData: [] } }) + return ( + {}} + onPreviousStep={() => {}} + /> + ) +} + +const openManualImport = async () => { + const user = userEvent.setup() + renderWithProviders() + await user.click(await screen.findByRole('button', { name: /manual import/i })) + return { user, dialog: within(await screen.findByRole('dialog')) } +} + +describe('CippWizardDevicePrepImport manual entry', () => { + it('requires manufacturer, model and serial number before a row can be added', async () => { + const { user, dialog } = await openManualImport() + + await user.type(dialog.getByLabelText('Manufacturer'), 'Dell') + + expect(await dialog.findByText(/Model, Serial Number are required/)).toBeInTheDocument() + expect(dialog.getByRole('button', { name: 'Add' })).toBeDisabled() + }, 20000) + + it('rejects values containing a comma', async () => { + const { user, dialog } = await openManualImport() + + await user.type(dialog.getByLabelText('Manufacturer'), 'Dell, Inc') + await user.type(dialog.getByLabelText('Model'), 'XPS 13') + await user.type(dialog.getByLabelText('Serial Number'), 'SN001') + + expect(await dialog.findByText(/Manufacturer may not contain a comma/)).toBeInTheDocument() + expect(dialog.getByRole('button', { name: 'Add' })).toBeDisabled() + }, 20000) + + it('adds a complete device to the table', async () => { + const { user, dialog } = await openManualImport() + + await user.type(dialog.getByLabelText('Manufacturer'), 'Dell') + await user.type(dialog.getByLabelText('Model'), 'XPS 13') + await user.type(dialog.getByLabelText('Serial Number'), 'SN001') + await user.click(dialog.getByRole('button', { name: 'Add' })) + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + // MRT virtualizes rows and jsdom has no layout engine, so cells are not + // rendered; the pagination summary is the observable proof the row landed. + expect(await screen.findByText('1-1 of 1', {}, { timeout: 10000 })).toBeInTheDocument() + }, 20000) +}) + +describe('CippWizardDevicePrepImport CSV import', () => { + const uploadCsv = async (content) => { + const user = userEvent.setup() + const { container } = renderWithProviders() + const input = container.querySelector('input[type="file"]') + const file = new File([content], 'identifiers.csv', { type: 'text/csv' }) + await user.upload(input, file) + } + + it('imports a headerless CSV in the Intune portal order', async () => { + await uploadCsv('Dell,XPS 13,SN001\nHP,EliteBook,SN002\n') + + expect(await screen.findByText('1-2 of 2', {}, { timeout: 10000 })).toBeInTheDocument() + }, 20000) + + it('imports a CSV with headers', async () => { + await uploadCsv('manufacturer,model,serialNumber\nDell,XPS 13,SN001\n') + + expect(await screen.findByText('1-1 of 1', {}, { timeout: 10000 })).toBeInTheDocument() + }, 20000) + + it('rejects rows with missing values instead of importing them', async () => { + await uploadCsv('Dell,,SN001\n') + + expect(await screen.findByText(/could not be imported/)).toBeInTheDocument() + expect(screen.getByText(/Model is required/)).toBeInTheDocument() + expect(screen.queryByText('1-1 of 1')).not.toBeInTheDocument() + }, 20000) + + it('rejects duplicate devices', async () => { + await uploadCsv('Dell,XPS 13,SN001\nDell,XPS 13,SN001\n') + + expect(await screen.findByText(/could not be imported/)).toBeInTheDocument() + expect(screen.getByText(/Duplicate device/)).toBeInTheDocument() + }, 20000) +}) diff --git a/tests/components/PrivateRoute.test.jsx b/tests/components/PrivateRoute.test.jsx index a8f8f4dc51a8..d3872b242cf9 100644 --- a/tests/components/PrivateRoute.test.jsx +++ b/tests/components/PrivateRoute.test.jsx @@ -19,6 +19,7 @@ vi.mock('../../src/api/ApiCall', () => ({ // /.auth/me return authState.swa }, + ApiPostCall: () => ({ mutate: vi.fn(), isPending: false }), })) // the gate page hosts the entire setup wizard via next/dynamic - the routing diff --git a/tests/mocks/api-call.js b/tests/mocks/api-call.js index 65c406fd0890..fe53d865b35e 100644 --- a/tests/mocks/api-call.js +++ b/tests/mocks/api-call.js @@ -31,6 +31,7 @@ export const paginatedResult = (rows = [], overrides = {}) => ({ export const postResult = (overrides = {}) => ({ mutate: vi.fn(), + reset: vi.fn(), isPending: false, isSuccess: false, isError: false, diff --git a/tests/mocks/baseline-tenant-fixture.json b/tests/mocks/baseline-tenant-fixture.json new file mode 100644 index 000000000000..07857a1799b2 --- /dev/null +++ b/tests/mocks/baseline-tenant-fixture.json @@ -0,0 +1,613 @@ +{ + "baseline": { + "GUID": "3ca8c0ec-9294-4060-8191-f1f2f3af37da", + "templateName": ".Baseline - Tenant", + "baselineName": ".Baseline - Tenant", + "description": "

    description

    ", + "assignedTenants": [ + "Exported Template" + ], + "assignments": { + "label": "Exported Template", + "value": "Exported Template", + "type": "Tenant" + }, + "exclusions": null, + "excludedTenants": [], + "alertEmails": "", + "alertWebhookUrl": "", + "disableScheduledRuns": false, + "standardsCount": 9, + "stageNames": [ + "Default" + ], + "stages": [ + { + "name": "Default", + "logic": "and", + "conditions": [], + "standards": [ + "ActivityBasedTimeout", + "AnonReportDisable", + "AuditLog", + "DisableBasicAuthSMTP", + "DisableGuestDirectory", + "EnablePronouns", + "FormsPhishingProtection", + "MailContacts", + "PhishProtection" + ], + "standardsConfig": [ + { + "standard": "ActivityBasedTimeout", + "instance": "ActivityBasedTimeout", + "variables": { + "timeout": "06:00:00" + }, + "remediateEnabled": false, + "alertEnabled": false, + "alertOnRemediate": false + }, + { + "standard": "AnonReportDisable", + "instance": "AnonReportDisable", + "variables": null, + "remediateEnabled": false, + "alertEnabled": false, + "alertOnRemediate": false + }, + { + "standard": "AuditLog", + "instance": "AuditLog", + "variables": null, + "remediateEnabled": false, + "alertEnabled": false, + "alertOnRemediate": false + }, + { + "standard": "DisableBasicAuthSMTP", + "instance": "DisableBasicAuthSMTP", + "variables": null, + "remediateEnabled": false, + "alertEnabled": false, + "alertOnRemediate": false + }, + { + "standard": "DisableGuestDirectory", + "instance": "DisableGuestDirectory", + "variables": null, + "remediateEnabled": false, + "alertEnabled": false, + "alertOnRemediate": false + }, + { + "standard": "EnablePronouns", + "instance": "EnablePronouns", + "variables": null, + "remediateEnabled": false, + "alertEnabled": false, + "alertOnRemediate": false + }, + { + "standard": "FormsPhishingProtection", + "instance": "FormsPhishingProtection", + "variables": null, + "remediateEnabled": false, + "alertEnabled": false, + "alertOnRemediate": false + }, + { + "standard": "MailContacts", + "instance": "MailContacts", + "variables": { + "SecurityContact": "support@bezalu.com", + "TechContact": "support@bezalu.com", + "GeneralContact": "support@bezalu.com", + "MarketingContact": "" + }, + "remediateEnabled": false, + "alertEnabled": false, + "alertOnRemediate": false + }, + { + "standard": "PhishProtection", + "instance": "PhishProtection", + "variables": null, + "remediateEnabled": false, + "alertEnabled": false, + "alertOnRemediate": false + } + ] + } + ], + "remediationPosture": "Report", + "updatedAt": 1787415499, + "updatedBy": "developer@localhost", + "occupancy": [ + { + "stage": 1, + "name": "Default", + "standardsCount": 9, + "tenants": [ + null + ], + "nextAdvanceAt": null + } + ], + "tenantStates": [] + }, + "definitions": [ + { + "name": "DisableBasicAuthSMTP", + "label": "Disable SMTP Basic Authentication", + "cat": "Exchange Standards", + "tag": [ + "CIS M365 7.0.0 (6.5.4)", + "NIST CSF 2.0 (PR.IR-01)" + ], + "impact": "Medium Impact", + "helpText": "Disables SMTP AUTH organization-wide, impacting POP and IMAP clients that rely on SMTP for sending emails. Default for new tenants. For more information, see the [Microsoft documentation](https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/authenticated-client-smtp-submission)", + "executiveText": "Disables outdated email authentication methods that are vulnerable to security attacks, forcing applications and devices to use modern, more secure authentication protocols. This reduces the risk of email-based security breaches and credential theft.", + "docsDescription": "Disables tenant-wide SMTP basic authentication, including for all explicitly enabled users, impacting POP and IMAP clients that rely on SMTP for sending emails. For more information, see the [Microsoft documentation](https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/authenticated-client-smtp-submission).", + "impactColour": "warning", + "addedDate": "2021-11-16", + "powershellEquivalent": "Set-TransportConfig -SmtpClientAuthenticationDisabled $true", + "appliesToTest": [ + "CISAMSEXO51", + "CIS_6_5_4", + "ZTNA21799" + ], + "recommendedBy": [ + "CIS", + "CIPP" + ], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "secureScoreImpact": 10, + "compare": "subset", + "variables": { + "disabled": { + "type": "switch", + "label": "SMTP basic authentication disabled", + "default": true, + "recommended": true + } + }, + "expected": { + "SmtpClientAuthenticationDisabled": "%disabled%", + "UsersWithSmtpAuthEnabled": [] + }, + "read": { + "cacheType": "ExoTransportConfig" + }, + "prepare": "Get-CIPPBaselineDisableBasicAuthSMTPState", + "remediate": { + "executor": "DisableBasicAuthSMTP", + "disabled": "%disabled%" + } + }, + { + "name": "ActivityBasedTimeout", + "label": "Enable Activity based Timeout", + "cat": "Global Standards", + "tag": [ + "CIS M365 7.0.0 (1.3.2)", + "spo_idle_session_timeout", + "NIST CSF 2.0 (PR.AA-03)" + ], + "impact": "Medium Impact", + "helpText": "Enables and sets Idle session timeout for Microsoft 365 to 1 hour. This policy affects most M365 web apps", + "executiveText": "Automatically logs out inactive users from Microsoft 365 applications after a specified time period to prevent unauthorized access to company data on unattended devices. This security measure protects against data breaches when employees leave workstations unlocked.", + "impactColour": "warning", + "addedDate": "2022-04-13", + "powershellEquivalent": "Portal or Graph API", + "appliesToTest": [ + "CIS_1_3_2", + "ZTNA21813", + "ZTNA21814", + "ZTNA21815" + ], + "recommendedBy": [ + "CIS" + ], + "requiredCapabilities": [], + "secureScoreImpact": 5, + "compare": "subset", + "variables": { + "timeout": { + "type": "autoComplete", + "label": "Idle session timeout", + "options": [ + { + "label": "1 Hour", + "value": "01:00:00" + }, + { + "label": "3 Hours", + "value": "03:00:00" + }, + { + "label": "6 Hours", + "value": "06:00:00" + }, + { + "label": "12 Hours", + "value": "12:00:00" + }, + { + "label": "24 Hours", + "value": "1.00:00:00" + } + ], + "default": "01:00:00", + "recommended": "01:00:00" + } + }, + "expected": { + "timeout": "%timeout%" + }, + "read": { + "cacheType": "ActivityBasedTimeoutPolicy" + }, + "prepare": "Get-CIPPBaselineActivityBasedTimeoutState", + "remediate": { + "executor": "ActivityBasedTimeout", + "timeout": "%timeout%" + } + }, + { + "name": "AnonReportDisable", + "label": "Enable Usernames instead of pseudo anonymised names in reports", + "cat": "Global Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Shows usernames instead of pseudo anonymised names in reports. This standard is required for reporting to work correctly.", + "executiveText": "Configures Microsoft 365 reports to display actual usernames instead of anonymized identifiers, enabling IT administrators to effectively troubleshoot issues and generate meaningful usage reports. This improves operational efficiency and system management capabilities.", + "docsDescription": "Microsoft announced some APIs and reports no longer return names, to comply with compliance and legal requirements in specific countries. This proves an issue for a lot of MSPs because those reports are often helpful for engineers. This standard applies a setting that shows usernames in those API calls / reports.", + "impactColour": "info", + "addedDate": "2021-11-16", + "powershellEquivalent": "Update-MgBetaAdminReportSetting -BodyParameter @{displayConcealedNames = $true}", + "recommendedBy": [ + "CIPP" + ], + "requiredCapabilities": [], + "secureScoreImpact": 0, + "compare": "subset", + "variables": null, + "expected": { + "displayConcealedNames": false + }, + "read": { + "cacheType": "AdminReportSettings" + }, + "remediate": { + "executor": "GraphRequest", + "requests": [ + { + "method": "PATCH", + "uri": "admin/reportSettings", + "body": { + "displayConcealedNames": false + } + } + ] + } + }, + { + "name": "AuditLog", + "label": "Enable the Unified Audit Log", + "cat": "Global Standards", + "tag": [ + "CIS M365 7.0.0 (3.1.1)", + "mip_search_auditlog", + "NIST CSF 2.0 (DE.CM-09)" + ], + "impact": "Low Impact", + "helpText": "Enables the Unified Audit Log for tracking and auditing activities. Also runs Enable-OrganizationCustomization if necessary.", + "executiveText": "Activates comprehensive activity logging across Microsoft 365 services to track user actions, system changes, and security events. This provides essential audit trails for compliance requirements, security investigations, and regulatory reporting.", + "impactColour": "info", + "addedDate": "2021-11-16", + "powershellEquivalent": "Enable-OrganizationCustomization", + "appliesToTest": [ + "CISAMSEXO171", + "CISAMSEXO173", + "CIS_3_1_1" + ], + "recommendedBy": [ + "CIS", + "CIPP" + ], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "enabled": { + "type": "switch", + "label": "Unified Audit Log ingestion enabled", + "default": true, + "recommended": true, + "locked": true + } + }, + "expected": { + "UnifiedAuditLogIngestionEnabled": "%enabled%" + }, + "read": { + "cacheType": "ExoAdminAuditLogConfig" + }, + "remediate": { + "executor": "ExoRequest", + "cmdlets": [ + { + "cmdlet": "Enable-OrganizationCustomization", + "params": null, + "continueOnError": true + }, + { + "cmdlet": "Set-AdminAuditLogConfig", + "params": { + "UnifiedAuditLogIngestionEnabled": "%enabled%" + } + } + ] + } + }, + { + "name": "DisableGuestDirectory", + "label": "Restrict guest user access to directory objects", + "cat": "Global Standards", + "tag": [ + "CIS M365 7.0.0 (5.1.6.2)", + "CISA (MS.AAD.5.1v1)", + "EIDSCA.AP14", + "EIDSCA.ST08", + "EIDSCA.ST09", + "NIST CSF 2.0 (PR.AA-05)", + "SMB1001 (2.8)" + ], + "impact": "Low Impact", + "helpText": "Disables Guest access to enumerate directory objects. This prevents guest users from seeing other users or guests in the directory.", + "executiveText": "Restricts external guest users from viewing the company's employee directory and organizational structure, protecting sensitive information about staff and internal groups. This security measure prevents unauthorized access to corporate contact information while still allowing necessary collaboration.", + "docsDescription": "Sets it so guests can view only their own user profile. Permission to view other users isn't allowed. Also restricts guest users from seeing the membership of groups they're in. See exactly what get locked down in the [Microsoft documentation.](https://learn.microsoft.com/en-us/entra/fundamentals/users-default-permissions)", + "impactColour": "info", + "addedDate": "2022-05-04", + "powershellEquivalent": "Set-AzureADMSAuthorizationPolicy -GuestUserRoleId '2af84b1e-32c8-42b7-82bc-daa82404023b'", + "appliesToTest": [ + "CIS_5_1_6_2", + "EIDSCAAP07", + "EIDSCAAP14", + "EIDSCAST08", + "EIDSCAST09", + "SMB1001_2_8", + "ZTNA21792" + ], + "recommendedBy": [ + "CIPP" + ], + "requiredCapabilities": [], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "guestUserRoleId": { + "type": "autoComplete", + "label": "Guest user access level", + "options": [ + { + "label": "Restricted access (guests can only see their own profile)", + "value": "2af84b1e-32c8-42b7-82bc-daa82404023b" + }, + { + "label": "Limited access (guests can see membership of non-hidden groups)", + "value": "10dae51f-b6af-4016-8d66-8c2a99b929b3" + }, + { + "label": "Same access as member users", + "value": "a0b1b346-4d3e-4e8b-98f8-753987be4970" + } + ], + "default": "2af84b1e-32c8-42b7-82bc-daa82404023b", + "recommended": "2af84b1e-32c8-42b7-82bc-daa82404023b" + } + }, + "expected": { + "guestUserRoleId": "%guestUserRoleId%" + }, + "read": { + "cacheType": "AuthorizationPolicy" + }, + "remediate": { + "executor": "GraphRequest", + "requests": [ + { + "method": "PATCH", + "asApp": false, + "uri": "policies/authorizationPolicy/authorizationPolicy", + "body": { + "guestUserRoleId": "%guestUserRoleId%" + } + } + ] + } + }, + { + "name": "EnablePronouns", + "label": "Enable Pronouns", + "cat": "Global Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Enables the Pronouns feature for the tenant. This allows users to set their pronouns in their profile.", + "executiveText": "Allows employees to display their preferred pronouns in their Microsoft 365 profiles, supporting inclusive workplace practices and helping colleagues communicate respectfully. This feature enhances diversity and inclusion initiatives while fostering a more welcoming work environment.", + "impactColour": "info", + "addedDate": "2024-06-05", + "powershellEquivalent": "Update-MgBetaAdminPeoplePronoun -IsEnabledInOrganization:$true", + "recommendedBy": [], + "requiredCapabilities": [], + "secureScoreImpact": 0, + "compare": "subset", + "variables": null, + "expected": { + "isEnabledInOrganization": true + }, + "read": { + "cacheType": "Pronouns" + }, + "remediate": { + "executor": "GraphRequest", + "requests": [ + { + "method": "PATCH", + "uri": "admin/people/pronouns", + "body": { + "isEnabledInOrganization": true + } + } + ] + } + }, + { + "name": "FormsPhishingProtection", + "label": "Enable internal phishing protection for Forms", + "cat": "Global Standards", + "tag": [ + "CIS M365 7.0.0 (1.3.5)", + "Security", + "PhishingProtection" + ], + "impact": "Low Impact", + "helpText": "Enables internal phishing protection for Microsoft Forms to help prevent malicious forms from being created and shared within the organization. This feature scans forms created by internal users for potential phishing content and suspicious patterns.", + "executiveText": "Automatically scans Microsoft Forms created by employees for malicious content and phishing attempts, preventing the creation and distribution of harmful forms within the organization. This protects against both internal threats and compromised accounts that might be used to distribute malicious content.", + "docsDescription": "Enables internal phishing protection for Microsoft Forms by setting the isInOrgFormsPhishingScanEnabled property to true. This security feature helps protect organizations from internal phishing attacks through Microsoft Forms by automatically scanning forms created by internal users for potential malicious content, suspicious links, and phishing patterns. When enabled, Forms will analyze form content and block or flag potentially dangerous forms before they can be shared within the organization.", + "impactColour": "info", + "addedDate": "2025-06-06", + "powershellEquivalent": "Graph API", + "appliesToTest": [ + "CIS_1_3_5" + ], + "recommendedBy": [ + "CIS", + "CIPP" + ], + "requiredCapabilities": [], + "secureScoreImpact": 0, + "compare": "subset", + "variables": null, + "expected": { + "isInOrgFormsPhishingScanEnabled": true + }, + "read": { + "cacheType": "FormsSettings" + }, + "remediate": { + "executor": "GraphRequest", + "requests": [ + { + "method": "PATCH", + "asApp": false, + "uri": "admin/forms/settings", + "body": { + "isInOrgFormsPhishingScanEnabled": true + } + } + ] + } + }, + { + "name": "MailContacts", + "label": "Set contact e-mails", + "cat": "Global Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Sets the organization's notification contacts: technical, security, marketing and general/privacy. Only configured contacts are enforced.", + "executiveText": "Keeps Microsoft's service, security and privacy notifications flowing to the right mailboxes instead of a former employee's.", + "docsDescription": "Grades only the configured contacts: marketing as contains, security+technical as a set against the technical notification list, and the general contact against the privacy profile. Remediation writes only the configured members.", + "impactColour": "info", + "addedDate": "2026-08-16", + "powershellEquivalent": "Graph: PATCH organization", + "recommendedBy": [], + "requiredCapabilities": [], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "GeneralContact": { + "type": "textField", + "label": "General/privacy contact email", + "omitWhenBlank": true + }, + "SecurityContact": { + "type": "textField", + "label": "Security contact email", + "omitWhenBlank": true + }, + "MarketingContact": { + "type": "textField", + "label": "Marketing contact email", + "omitWhenBlank": true + }, + "TechContact": { + "type": "textField", + "label": "Technical contact email", + "omitWhenBlank": true + } + }, + "read": { + "cacheType": "Organization" + }, + "prepare": "Get-CIPPBaselineMailContactsState", + "remediate": { + "executor": "MailContacts", + "generalContact": "%GeneralContact%", + "securityContact": "%SecurityContact%", + "marketingContact": "%MarketingContact%", + "techContact": "%TechContact%" + } + }, + { + "name": "PhishProtection", + "label": "Enable Phishing Protection system via branding CSS", + "cat": "Global Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Adds branding to the logon page that only appears if the url is not login.microsoftonline.com. This potentially prevents AITM attacks via EvilNginx, and automatically generates alerts if a clone of your login page is found.", + "executiveText": "Adds a hidden canary to the company sign-in page that exposes cloned phishing pages and alerts when one is found, protecting staff credentials from adversary-in-the-middle attacks.", + "docsDescription": "Grades whether the default branding localization's custom CSS contains the tenant's clone-detection canary (the clone.cipp.app background-image URL carrying this instance's CIPPURL from the Config table). The branding singleton reads live. Remediation strips a known malformed variant, creates the default localization when missing (Accept-Language 0, tolerating already-exists), and APPENDS the canary to the existing CSS - operator customizations are never overwritten. Report and warn are disabled as in the classic: this standard acts through remediation only.", + "impactColour": "info", + "addedDate": "2026-08-16", + "powershellEquivalent": "Portal only", + "recommendedBy": [], + "requiredCapabilities": [ + "AAD_PREMIUM", + "AAD_PREMIUM_P2", + "OFFICE_BUSINESS" + ], + "disabledFeatures": { + "report": true, + "warn": true, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": null, + "read": null, + "prepare": "Get-CIPPBaselinePhishProtectionState", + "remediate": { + "executor": "PhishProtection" + } + } + ] +} diff --git a/tests/pages/BaselineTemplateEditor.seeding.test.jsx b/tests/pages/BaselineTemplateEditor.seeding.test.jsx new file mode 100644 index 000000000000..26f878079616 --- /dev/null +++ b/tests/pages/BaselineTemplateEditor.seeding.test.jsx @@ -0,0 +1,66 @@ +import React from 'react' +import { describe, it, expect, vi } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithProviders } from '../test-utils' +import router from '../mocks/next-router' +import fixture from '../mocks/baseline-tenant-fixture.json' + +vi.mock('../../src/api/ApiCall', async () => (await import('../mocks/api-call')).apiCallMock()) +import { api, getResult, postResult } from '../mocks/api-call' + +import Page from '../../src/pages/tenant/baselines/template.jsx' + +// Route ApiGetCall by url with STABLE result identities (fresh literals per call loop +// data-sync effects - see mocks/api-call.js). +const baselinesResult = getResult({ data: [fixture.baseline] }) +const definitionsResult = getResult({ data: fixture.definitions }) +const customVariablesResult = getResult({ data: { Results: [] } }) +const emptyResult = getResult({ isSuccess: false }) +api.get = (opts) => { + if (opts?.url === '/api/ListBaselines') return baselinesResult + if (opts?.url === '/api/ListBaselineStandards') return definitionsResult + if (opts?.url === '/api/ListCustomVariables') return customVariablesResult + return emptyResult +} +api.post = postResult() + +router.query = { id: fixture.baseline.GUID } +router.pathname = '/tenant/baselines/template' + +describe('Baseline template editor - migrated variable seeding', () => { + it('shows the saved MailContacts addresses after expanding the standard', async () => { + const user = userEvent.setup() + renderWithProviders() + + // Template loaded: its name is in the form and the standard is listed. + await waitFor(() => { + expect(screen.getByText('Set contact e-mails')).toBeInTheDocument() + }) + + // Expand the accordion the way an operator does (details mount lazily). + await user.click(screen.getByText('Set contact e-mails')) + + const inputs = await screen.findAllByRole('textbox') + const byLabel = {} + for (const input of inputs) { + const label = input.closest('.MuiFormControl-root')?.querySelector('label')?.textContent + if (label) byLabel[label] = input.value + } + console.log('DBG editor fields:', JSON.stringify(byLabel)) + + await waitFor(() => { + const security = screen + .getAllByRole('textbox') + .find( + (input) => + input + .closest('.MuiFormControl-root') + ?.querySelector('label') + ?.textContent?.includes('Security contact email') + ) + expect(security).toBeTruthy() + expect(security.value).toBe('support@bezalu.com') + }) + }, 30000) +}) diff --git a/tests/pages/MessageEncryptionPage.test.jsx b/tests/pages/MessageEncryptionPage.test.jsx index c378ae2bb994..c6e2e72cbe9f 100644 --- a/tests/pages/MessageEncryptionPage.test.jsx +++ b/tests/pages/MessageEncryptionPage.test.jsx @@ -32,10 +32,15 @@ describe('Message Encryption page', () => { beforeEach(() => { vi.clearAllMocks() api.post = postResult() - api.paginated = paginatedResult([ - { displayName: 'Admin', UPN: 'admin@contoso.com' }, - { displayName: 'Helpdesk', UPN: 'helpdesk@contoso.com' }, - ]) + // ListMailboxes returns a bare array (no Results wrapper, the page's api sets no dataKey) + api.paginated = paginatedResult([], { + data: { + pages: [[ + { displayName: 'Admin', UPN: 'admin@contoso.com' }, + { displayName: 'Helpdesk', UPN: 'helpdesk@contoso.com' }, + ]], + }, + }) }) it('renders the current IRM state for the tenant', async () => { @@ -123,6 +128,27 @@ describe('Message Encryption page', () => { }) }) + it('posts the Set action with both encryption switches', async () => { + const user = userEvent.setup() + // AzureRMS already on, Encrypt button off — the state the standard fix was about + api.get = getResult({ data: irmConfig() }) + renderWithProviders() + + await screen.findByText('Current Configuration') + await user.click(screen.getByRole('switch', { name: /Show the Encrypt button/i })) + await user.click(screen.getByRole('button', { name: 'Submit' })) + + expect(api.post.mutate).toHaveBeenCalledWith({ + url: '/api/ExecIRMConfiguration', + data: { + tenantFilter: 'testdomain.com', + Action: 'Set', + AzureRMSLicensingEnabled: true, + SimplifiedClientAccessEnabled: true, + }, + }) + }) + it('surfaces a load failure', async () => { api.get = getResult({ isSuccess: false, isError: true, data: undefined }) renderWithProviders() diff --git a/tests/pages/UnauthenticatedPage.test.jsx b/tests/pages/UnauthenticatedPage.test.jsx index 49507e700c88..1a4d9e315cf6 100644 --- a/tests/pages/UnauthenticatedPage.test.jsx +++ b/tests/pages/UnauthenticatedPage.test.jsx @@ -18,6 +18,7 @@ vi.mock('../../src/api/ApiCall', () => ({ // /.auth/me and /version.json return authState.swa }, + ApiPostCall: () => ({ mutate: vi.fn(), isPending: false }), })) const successResult = (data) => ({ From b1238941d1f14a0fc16416e8df5161afa0b17950 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 24 Aug 2026 10:51:36 -0400 Subject: [PATCH 10/10] Downgrade Node and jsdom versions in package.json Signed-off-by: John Duprey --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 8ba528f061ae..6fadedda3a77 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ }, "license": "AGPL-3.0", "engines": { - "node": "^22.22.2" + "node": "^22.22.0" }, "repository": { "type": "git", @@ -135,7 +135,7 @@ "eslint": "^9.39.4", "eslint-config-next": "^16.2.10", "eslint-config-prettier": "^10.1.8", - "jsdom": "30.0.1", + "jsdom": "29.0.1", "msw": "2.12.14", "msw-storybook-addon": "3.0.0", "playwright": "1.59.1", @@ -153,4 +153,4 @@ "resolutions": { "vite": "7.3.6" } -} \ No newline at end of file +}