diff --git a/package.json b/package.json
index 748f3dee1700..5476801e962e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "cipp",
- "version": "10.8.2",
+ "version": "10.8.3",
"author": "CIPP Contributors",
"homepage": "https://cipp.app/",
"bugs": {
diff --git a/public/version.json b/public/version.json
index e5ab9ee3cb92..682cb83714dc 100644
--- a/public/version.json
+++ b/public/version.json
@@ -1,3 +1,3 @@
{
- "version": "10.8.2"
-}
+ "version": "10.8.3"
+}
\ No newline at end of file
diff --git a/src/components/CippBaselines/CippBaselineStandardItem.jsx b/src/components/CippBaselines/CippBaselineStandardItem.jsx
index c113c061d12c..a2ac421247cd 100644
--- a/src/components/CippBaselines/CippBaselineStandardItem.jsx
+++ b/src/components/CippBaselines/CippBaselineStandardItem.jsx
@@ -350,14 +350,14 @@ export const CippBaselineStandardItem = ({
- {standard.prepare ? (
+ {standard.prepare || standard.package ? (
// Template-backed standards (CA/Intune): the real expected value is the FULL
- // selected template, normalized by the engine at run time - a rendered
- // preview here would show only the template reference and mislead.
+ // selected template (or every template in the package), resolved by the
+ // engine at run time - a rendered preview here would mislead.
- The expected configuration is the full selected template - use the
- preview button on the template picker to inspect it. The engine
- compares every setting in it against the tenant on each run.
+ {standard.package
+ ? 'This deploys and drift-checks every template tagged with the selected package. Membership is resolved fresh on every run: tagging a template with this package adds it to the baseline automatically, untagging removes it. All templates in the package share the options configured above.'
+ : 'The expected configuration is the full selected template - use the preview button on the template picker to inspect it. The engine compares every setting in it against the tenant on each run.'}
) : (
<>
diff --git a/src/components/CippComponents/CIPPDeviceCodeButton.js b/src/components/CippComponents/CIPPDeviceCodeButton.js
deleted file mode 100644
index 16711f8d1332..000000000000
--- a/src/components/CippComponents/CIPPDeviceCodeButton.js
+++ /dev/null
@@ -1,252 +0,0 @@
-import { useState, useEffect } from "react";
-import {
- Alert,
- Button,
- Typography,
- CircularProgress,
- Box,
-} from "@mui/material";
-import { ApiGetCall } from "../../api/ApiCall";
-
-/**
- * CIPPDeviceCodeButton - A button component for Microsoft 365 OAuth authentication using device code flow
- *
- * @param {Object} props - Component props
- * @param {Function} props.onAuthSuccess - Callback function called when authentication is successful with token data
- * @param {Function} props.onAuthError - Callback function called when authentication fails with error data
- * @param {string} props.buttonText - Text to display on the button (default: "Login with Device Code")
- * @param {boolean} props.showResults - Whether to show authentication results in the component (default: true)
- * @returns {JSX.Element} The CIPPDeviceCodeButton component
- */
-export const CIPPDeviceCodeButton = ({
- onAuthSuccess,
- onAuthError,
- buttonText = "Login with Device Code",
- showResults = true,
-}) => {
- const [authInProgress, setAuthInProgress] = useState(false);
- const [authError, setAuthError] = useState(null);
- const [deviceCodeInfo, setDeviceCodeInfo] = useState(null);
- const [currentStep, setCurrentStep] = useState(0);
- const [pollInterval, setPollInterval] = useState(null);
- const [tokens, setTokens] = useState({
- accessToken: null,
- refreshToken: null,
- accessTokenExpiresOn: null,
- refreshTokenExpiresOn: null,
- username: null,
- tenantId: null,
- onmicrosoftDomain: null,
- });
-
- // Get application ID information from API
- const appIdInfo = ApiGetCall({
- url: `/api/ExecListAppId`,
- queryKey: `ExecListAppId`,
- waiting: true,
- });
-
- // Handle closing the error
- const handleCloseError = () => {
- setAuthError(null);
- };
-
- // Clear polling interval when component unmounts
- useEffect(() => {
- return () => {
- if (pollInterval) {
- clearInterval(pollInterval);
- }
- };
- }, [pollInterval]);
-
- // Start device code authentication
- const startDeviceCodeAuth = async () => {
- try {
- setAuthInProgress(true);
- setAuthError(null);
- setDeviceCodeInfo(null);
- setCurrentStep(1);
-
- // Call the API to start device code flow
- const response = await fetch(`/api/ExecSAMSetup?CreateSAM=true`, {
- method: 'GET',
- headers: {
- 'Content-Type': 'application/json',
- },
- });
-
- const data = await response.json();
-
- if (response.ok && data.code) {
- // Store device code info
- setDeviceCodeInfo({
- user_code: data.code,
- verification_uri: data.url,
- expires_in: 900, // Default to 15 minutes if not provided
- });
-
- // Start polling for token
- const interval = setInterval(checkAuthStatus, 5000);
- setPollInterval(interval);
- } else {
- // Error getting device code
- setAuthError({
- errorCode: "device_code_error",
- errorMessage: data.message || "Failed to get device code",
- timestamp: new Date().toISOString(),
- });
- setAuthInProgress(false);
- if (onAuthError) onAuthError(error);
- }
- } catch (error) {
- console.error("Error starting device code authentication:", error);
- setAuthError({
- errorCode: "device_code_error",
- errorMessage: error.message || "An error occurred during device code authentication",
- timestamp: new Date().toISOString(),
- });
- setAuthInProgress(false);
- if (onAuthError) onAuthError(error);
- }
- };
-
- // Check authentication status
- const checkAuthStatus = async () => {
- try {
- // Call the API to check auth status
- const response = await fetch(`/api/ExecSAMSetup?CheckSetupProcess=true&step=1`, {
- method: 'GET',
- headers: {
- 'Content-Type': 'application/json',
- },
- });
-
- const data = await response.json();
-
- if (response.ok) {
- if (data.step === 2) {
- // Authentication successful
- clearInterval(pollInterval);
- setPollInterval(null);
-
- // Process token data
- const tokenData = {
- accessToken: "Successfully authenticated",
- refreshToken: "Token stored on server",
- accessTokenExpiresOn: new Date(Date.now() + 3600 * 1000), // 1 hour from now
- refreshTokenExpiresOn: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), // 90 days from now
- username: "authenticated user",
- tenantId: data.tenantId || "unknown",
- onmicrosoftDomain: null,
- };
-
- // Store tokens in component state
- setTokens(tokenData);
- setDeviceCodeInfo(null);
- setCurrentStep(2);
-
- // Call the onAuthSuccess callback if provided
- if (onAuthSuccess) onAuthSuccess(tokenData);
-
- // Update UI state
- setAuthInProgress(false);
- }
- } else {
- // Error checking auth status
- clearInterval(pollInterval);
- setPollInterval(null);
-
- setAuthError({
- errorCode: "auth_status_error",
- errorMessage: data.message || "Failed to check authentication status",
- timestamp: new Date().toISOString(),
- });
- setAuthInProgress(false);
- if (onAuthError) onAuthError({
- errorCode: "auth_status_error",
- errorMessage: data.message || "Failed to check authentication status",
- timestamp: new Date().toISOString(),
- });
- }
- } catch (error) {
- console.error("Error checking auth status:", error);
- // Don't stop polling on transient errors
- }
- };
-
- return (
-
-
-
- {!appIdInfo.isLoading &&
- !appIdInfo?.data?.applicationId && (
-
- The Application ID is not valid. Please check your configuration.
-
- )
- }
-
- {showResults && (
-
- {deviceCodeInfo && authInProgress ? (
-
- Device Code Authentication
-
- To sign in, use a web browser to open the page {deviceCodeInfo.verification_uri} and enter the code {deviceCodeInfo.user_code} to authenticate.
-
-
- Code expires in {Math.round(deviceCodeInfo.expires_in / 60)} minutes
-
-
- ) : tokens.accessToken ? (
-
- Authentication Successful
-
- You've successfully refreshed your token using device code flow.
-
- {tokens.tenantId && (
-
- Tenant ID: {tokens.tenantId}
-
- )}
-
- ) : authError ? (
-
- Authentication Error: {authError.errorCode}
- {authError.errorMessage}
-
- Time: {authError.timestamp}
-
-
-
-
-
- ) : null}
-
- )}
-
- );
-};
-
-export default CIPPDeviceCodeButton;
\ No newline at end of file
diff --git a/src/components/CippComponents/CIPPM365OAuthButton.jsx b/src/components/CippComponents/CIPPM365OAuthButton.jsx
index fbc742fe8ec8..504aa2404279 100644
--- a/src/components/CippComponents/CIPPM365OAuthButton.jsx
+++ b/src/components/CippComponents/CIPPM365OAuthButton.jsx
@@ -1,4 +1,4 @@
-import { useState, useEffect } from 'react'
+import { useState, useEffect, useRef } from 'react'
import { Alert, Button, Typography, CircularProgress, Box } from '@mui/material'
import { Microsoft, Login, Refresh } from '@mui/icons-material'
import { ApiGetCall } from '../../api/ApiCall'
@@ -17,6 +17,7 @@ export const CIPPM365OAuthButton = ({
autoStartDeviceLogon = false,
validateServiceAccount = true,
promptBeforeAuth = false,
+ disabled = false,
}) => {
const [authInProgress, setAuthInProgress] = useState(false)
const [authError, setAuthError] = useState(null)
@@ -40,6 +41,86 @@ export const CIPPM365OAuthButton = ({
waiting: true,
})
+ // Closing the device login window does not cancel anything - the device code stays
+ // valid server side until it expires and can be completed in any browser. So the
+ // watcher below never stops the poll; it only tracks whether the window is gone so
+ // the UI can offer a way back in instead of sitting on a disabled "Authenticating..."
+ // button for the full 15 minutes.
+ const devicePopupRef = useRef(null)
+ const devicePopupWatcherRef = useRef(null)
+ const devicePollIdRef = useRef(0)
+ const [devicePopupClosed, setDevicePopupClosed] = useState(false)
+
+ const stopDevicePopupWatcher = () => {
+ if (devicePopupWatcherRef.current) {
+ clearInterval(devicePopupWatcherRef.current)
+ devicePopupWatcherRef.current = null
+ }
+ }
+
+ const openDeviceLoginPopup = () => {
+ const width = 500
+ const height = 600
+ const left = window.screen.width / 2 - width / 2
+ const top = window.screen.height / 2 - height / 2
+
+ const popup = window.open(
+ 'https://microsoft.com/devicelogin',
+ 'deviceLoginPopup',
+ `width=${width},height=${height},left=${left},top=${top}`
+ )
+
+ stopDevicePopupWatcher()
+ devicePopupRef.current = popup
+
+ // A blocked popup is indistinguishable from a closed one as far as the user is
+ // concerned - both leave them with no window to sign in through.
+ if (!popup) {
+ setDevicePopupClosed(true)
+ return null
+ }
+
+ setDevicePopupClosed(false)
+ devicePopupWatcherRef.current = setInterval(() => {
+ if (popup.closed) {
+ stopDevicePopupWatcher()
+ setDevicePopupClosed(true)
+ }
+ }, 1000)
+
+ return popup
+ }
+
+ const closeDeviceLoginPopup = () => {
+ stopDevicePopupWatcher()
+ const popup = devicePopupRef.current
+ if (popup && !popup.closed) {
+ popup.close()
+ }
+ devicePopupRef.current = null
+ setDevicePopupClosed(false)
+ }
+
+ useEffect(() => stopDevicePopupWatcher, [])
+
+ // Reopening the window is not offered: a user code is consumed the moment it is entered,
+ // so once someone has typed it in, re-entering the same code fails. Closing the window
+ // part way through a sign-in is therefore unrecoverable except with a fresh code. The
+ // poll is left running anyway, because the sign-in may still be getting finished at
+ // microsoft.com/devicelogin in another browser.
+ const canRestartDeviceLogin = useDeviceCode && authInProgress && devicePopupClosed
+
+ const restartDeviceLogin = async () => {
+ // Supersede the in-flight poll before requesting a new code, or it would keep
+ // polling the old device_code alongside the new one.
+ devicePollIdRef.current += 1
+ closeDeviceLoginPopup()
+ setAuthInProgress(false)
+ setAuthError(null)
+ setDeviceCodeInfo(null)
+ await retrieveDeviceCode()
+ }
+
const handleCloseError = () => {
setAuthError(null)
}
@@ -125,29 +206,24 @@ export const CIPPM365OAuthButton = ({
const appId =
applicationId || appIdInfo?.data?.applicationId || '1b730954-1685-4b74-9bfd-dac224a7b894' // Default to MS Graph Explorer app ID
- // Open popup to device login page
- const width = 500
- const height = 600
- const left = window.screen.width / 2 - width / 2
- const top = window.screen.height / 2 - height / 2
-
- const popup = window.open(
- 'https://microsoft.com/devicelogin',
- 'deviceLoginPopup',
- `width=${width},height=${height},left=${left},top=${top}`
- )
+ // Open popup to device login page. If it is closed or blocked the poll below keeps
+ // running - the button turns into "Reopen sign-in window" rather than locking up.
+ openDeviceLoginPopup()
// Start polling for token
const pollInterval = deviceCodeInfo.interval || 5
const expiresIn = deviceCodeInfo.expires_in || 900
const startTime = Date.now()
+ // Identifies this attempt. Starting over bumps the ref, which retires this poll
+ // rather than leaving it chasing a device code the user has abandoned.
+ const pollId = ++devicePollIdRef.current
const pollForToken = async () => {
+ if (devicePollIdRef.current !== pollId) return
+
// Check if we've exceeded the expiration time
if (Date.now() - startTime >= expiresIn * 1000) {
- if (popup && !popup.closed) {
- popup.close()
- }
+ closeDeviceLoginPopup()
setAuthError({
errorCode: 'timeout',
errorMessage: 'Device code authentication timed out',
@@ -158,17 +234,19 @@ export const CIPPM365OAuthButton = ({
}
try {
- // Poll for token using our API endpoint
+ // Poll for token using our API endpoint. The scope has to match the one the device
+ // code was issued for - omitting it here left the poll falling back to the API's
+ // default instead.
const tokenResponse = await fetch(
- `/api/ExecDeviceCodeLogon?operation=checkToken&clientId=${appId}&deviceCode=${deviceCodeInfo.device_code}`
+ `/api/ExecDeviceCodeLogon?operation=checkToken&clientId=${appId}&deviceCode=${
+ deviceCodeInfo.device_code
+ }&scope=${encodeURIComponent(scope)}`
)
const tokenData = await tokenResponse.json()
if (tokenResponse.ok && tokenData.status === 'success') {
// Successfully got token
- if (popup && !popup.closed) {
- popup.close()
- }
+ closeDeviceLoginPopup()
handleTokenResponse(tokenData)
} else if (
tokenData.error === 'authorization_pending' ||
@@ -181,9 +259,7 @@ export const CIPPM365OAuthButton = ({
setTimeout(pollForToken, (pollInterval + 5) * 1000)
} else {
// Other error
- if (popup && !popup.closed) {
- popup.close()
- }
+ closeDeviceLoginPopup()
setAuthError({
errorCode: tokenData.error || 'token_error',
errorMessage: tokenData.error_description || 'Failed to get token',
@@ -296,7 +372,7 @@ export const CIPPM365OAuthButton = ({
const msalConfig = {
auth: {
clientId: appId,
- authority: `https://login.microsoftonline.com/common`,
+ authority: `https://login.microsoftonline.com/organizations`,
redirectUri: `${window.location.origin}/authredirect`,
},
}
@@ -306,34 +382,44 @@ export const CIPPM365OAuthButton = ({
scopes: [scope],
}
- // Generate PKCE code verifier and challenge
- const generateCodeVerifier = () => {
- const array = new Uint8Array(32)
- window.crypto.getRandomValues(array)
- return Array.from(array, (byte) => ('0' + (byte & 0xff).toString(16)).slice(-2)).join('')
+ // crypto.subtle is only exposed in a secure context. Without this guard an instance
+ // served over plain HTTP fails on the digest below with an opaque TypeError.
+ if (!window.crypto?.subtle) {
+ const error = {
+ errorCode: 'insecure_context',
+ errorMessage:
+ 'Authentication requires a secure context. Serve CIPP over HTTPS (or localhost) and try again.',
+ timestamp: new Date().toISOString(),
+ }
+ setAuthError(error)
+ if (onAuthError) onAuthError(error)
+ setAuthInProgress(false)
+ return
}
- const codeVerifier = generateCodeVerifier()
- const codeChallenge = codeVerifier
- const state = Math.random().toString(36).substring(2, 15)
- const authUrl =
- `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?` +
- `client_id=${appId}` +
- `&response_type=code` +
- `&redirect_uri=${encodeURIComponent(window.location.origin)}/authredirect` +
- `&scope=${encodeURIComponent(scope)}` +
- `&code_challenge=${codeChallenge}` +
- `&code_challenge_method=plain` +
- `&state=${state}` +
- `&prompt=select_account`
+ const base64UrlEncode = (bytes) =>
+ btoa(String.fromCharCode(...new Uint8Array(bytes)))
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=+$/, '')
+
+ const randomUrlSafeString = (byteLength) => {
+ const array = new Uint8Array(byteLength)
+ window.crypto.getRandomValues(array)
+ return base64UrlEncode(array)
+ }
const width = 500
const height = 600
const left = window.screen.width / 2 - width / 2
const top = window.screen.height / 2 - height / 2
+ // Open the window before computing the challenge below. window.open only succeeds
+ // while the click's user activation is still live, and awaiting the SHA-256 digest
+ // first spends it - browsers then treat the call as an unsolicited popup and block
+ // it. Open a blank window synchronously and navigate it once the URL is ready.
const popup = window.open(
- authUrl,
+ '',
'msalAuthPopup',
`width=${width},height=${height},left=${left},top=${top}`
)
@@ -353,6 +439,36 @@ export const CIPPM365OAuthButton = ({
return
}
+ // Generate PKCE code verifier and S256 challenge
+ const codeVerifier = randomUrlSafeString(32)
+ const codeChallenge = base64UrlEncode(
+ await window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier))
+ )
+ const state = randomUrlSafeString(16)
+ // prompt=login, not select_account: this flow mints the refresh token CIPP runs on, and
+ // Entra stamps that token with the authentication context of the sign-in that created it
+ // (including the protocol flow, which Conditional Access re-evaluates on every redemption).
+ // select_account can complete via SSO from an existing session - including the one the
+ // device code step establishes at microsoft.com/devicelogin in this same browser - which
+ // would carry a device-code-flow marker forward instead of clearing it.
+ // /organizations, not /common: CIPP-SAM is signInAudience AzureADMultipleOrgs, so it
+ // supports work and school accounts only. /common additionally advertises personal
+ // Microsoft accounts, letting someone pick one and fail later with a confusing error
+ // instead of being told up front that the account cannot be used. It also matches the
+ // authority the device code flow uses.
+ const authUrl =
+ `https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize?` +
+ `client_id=${appId}` +
+ `&response_type=code` +
+ `&redirect_uri=${encodeURIComponent(window.location.origin)}/authredirect` +
+ `&scope=${encodeURIComponent(scope)}` +
+ `&code_challenge=${codeChallenge}` +
+ `&code_challenge_method=S256` +
+ `&state=${state}` +
+ `&prompt=login`
+
+ popup.location = authUrl
+
// Function to actually exchange the authorization code for tokens
const handleAuthorizationCode = async (code, receivedState) => {
// Verify the state parameter matches what we sent (security check)
@@ -393,7 +509,7 @@ export const CIPPM365OAuthButton = ({
},
body: JSON.stringify({
tokenRequest,
- tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
+ tokenUrl: 'https://login.microsoftonline.com/organizations/oauth2/v2.0/token',
tenantId: appId, // Pass the tenant ID to retrieve the correct client secret
}),
})
@@ -401,10 +517,15 @@ export const CIPPM365OAuthButton = ({
// Parse the token response
tokenData = await tokenResponse.json()
- // Check if it's the AADSTS650051 error (service principal already exists)
+ // AADSTS650051: service principal already exists.
+ // AADSTS7000215: the client secret is not valid *yet*. The wizard mints a new
+ // secret on the previous step and arrives here seconds later, but Entra can take
+ // minutes to replicate it. Retrying covers the fast case; the message below covers
+ // the rest, since waiting it out would outlive the authorization code.
if (
tokenData.error === 'invalid_client' &&
- tokenData.error_description?.includes('AADSTS650051')
+ (tokenData.error_description?.includes('AADSTS650051') ||
+ tokenData.error_description?.includes('AADSTS7000215'))
) {
retryCount++
if (retryCount <= maxRetries) {
@@ -419,10 +540,12 @@ export const CIPPM365OAuthButton = ({
// Check if the response contains an error
if (tokenData.error) {
+ const secretNotReady = tokenData.error_description?.includes('AADSTS7000215')
const error = {
errorCode: tokenData.error || 'token_error',
- errorMessage:
- tokenData.error_description || 'Failed to exchange authorization code for tokens',
+ errorMessage: secretNotReady
+ ? 'The application secret created for CIPP is not active yet. Microsoft can take several minutes to replicate a new secret across Entra ID. Wait a few minutes and run this step again - nothing needs to be recreated.'
+ : tokenData.error_description || 'Failed to exchange authorization code for tokens',
timestamp: new Date().toISOString(),
}
setAuthError(error)
@@ -551,10 +674,11 @@ export const CIPPM365OAuthButton = ({
// a short grace period before treating it as a cancellation. Without this,
// closing the sign-in window left the button stuck on "Authenticating..."
// until the 10-minute timeout.
+ let closeGraceTimer = null
const popupWatcher = setInterval(() => {
if (popup.closed) {
clearInterval(popupWatcher)
- setTimeout(() => {
+ closeGraceTimer = setTimeout(() => {
if (!resultReceived) {
cleanup()
const error = {
@@ -575,6 +699,12 @@ export const CIPPM365OAuthButton = ({
channel.close()
clearTimeout(authTimeout)
clearInterval(popupWatcher)
+ // The grace timer was previously left running. On the happy path - where the
+ // callback posts its result and then closes the popup - it would still be pending
+ // after cleanup, and if the user started another attempt inside that window it
+ // fired against the new one, clearing its progress state and reporting a
+ // cancellation for a sign-in that was still going.
+ clearTimeout(closeGraceTimer)
}
}
@@ -633,7 +763,14 @@ export const CIPPM365OAuthButton = ({
- {authInProgress ? (
+ {authInProgress && devicePopupClosed ? (
+ <>
+ The sign-in window was closed. If you are still finishing at{' '}
+ microsoft.com/devicelogin in another browser, CIPP is still
+ waiting. If you had already entered the code, it cannot be used again - start
+ over below to get a new one.
+ >
+ ) : authInProgress ? (
<>
If the popup was blocked or you closed it, you can also go to{' '}
microsoft.com/devicelogin manually and enter the code shown
@@ -733,15 +870,21 @@ export const CIPPM365OAuthButton = ({
)
diff --git a/src/components/CippComponents/CippAutocomplete.jsx b/src/components/CippComponents/CippAutocomplete.jsx
index 06a467903d17..5a1953382052 100644
--- a/src/components/CippComponents/CippAutocomplete.jsx
+++ b/src/components/CippComponents/CippAutocomplete.jsx
@@ -24,6 +24,11 @@ const MemoTextField = React.memo(function MemoTextField({
params,
label,
placeholder,
+ // Field-level required: asterisk on the label. HTML5 required is separate because
+ // Autocomplete (especially multiple) clears the input after selection — a static
+ // required on the input would falsely block submit even when chips/value exist.
+ required = false,
+ htmlRequired = false,
// Autocomplete-specific props that must not be forwarded to TextField/DOM
getOptionLabel,
isOptionEqualToValue,
@@ -43,11 +48,12 @@ const MemoTextField = React.memo(function MemoTextField({
label={label}
placeholder={placeholder}
{...otherProps}
+ required={htmlRequired}
slotProps={{
inputLabel: {
shrink: true,
sx: { transition: 'none' },
- required: otherProps.required,
+ required,
},
input: {
...InputProps,
@@ -138,6 +144,13 @@ export const CippAutoComplete = React.forwardRef((props, ref) => {
}
}, [value, defaultValue])
+ // Controlled value wins; otherwise use the onChange-tracked selection (FormComponent
+ // often drives via defaultValue + onChange rather than a controlled value prop).
+ const currentSelection = value !== undefined && value !== null ? value : internalValue
+ const hasSelection = multiple
+ ? Array.isArray(currentSelection) && currentSelection.length > 0
+ : currentSelection != null && currentSelection !== ''
+
// This is our paginated call
const actionGetRequest = ApiGetCallWithPagination({
...getRequestInfo,
@@ -611,6 +624,7 @@ export const CippAutoComplete = React.forwardRef((props, ref) => {
label={label}
placeholder={placeholder}
required={required}
+ htmlRequired={required && !hasSelection}
{...other}
/>
{api?.url && api?.showRefresh && (
diff --git a/src/components/CippPdf/ReportDocument.jsx b/src/components/CippPdf/ReportDocument.jsx
index c9f104aad7ba..5ea627587a0b 100644
--- a/src/components/CippPdf/ReportDocument.jsx
+++ b/src/components/CippPdf/ReportDocument.jsx
@@ -55,8 +55,7 @@ export const ReportDocument = ({
// The report's own footer wording, used when branding configures none.
footerLabel,
- // Resolved CIPP variables, from `useReportVariables`. Without them a footer configured with
- // `%cippurl%` or a custom variable ships with the token still written in it.
+ // Resolved CIPP variables, from `useReportVariables`.
variables: cippVariables,
size = DEFAULT_PAGE_SETUP.size,
@@ -73,10 +72,8 @@ export const ReportDocument = ({
generatedOn ??
new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
- // What every `%variable%` resolves to anywhere in this report. CIPP's own come first and the
- // report's three override them: `%reportname%` and `%reportdate%` exist nowhere else, and this
- // report's subject is the authority on `%tenantname%` — it still resolves before the fetch lands,
- // which a footer that has always worked should not have to wait for.
+ // What every `%variable%` resolves to in this report. The report's own three override CIPP's,
+ // so `%tenantname%` still resolves before the variables fetch lands.
const variables = {
...cippVariables,
tenantname: tenantName || 'Organization',
diff --git a/src/components/CippPdf/reportPdfPrimitives.jsx b/src/components/CippPdf/reportPdfPrimitives.jsx
index 86b292623f1e..c671983dba19 100644
--- a/src/components/CippPdf/reportPdfPrimitives.jsx
+++ b/src/components/CippPdf/reportPdfPrimitives.jsx
@@ -173,10 +173,7 @@ export const ReportPage = ({
}) => (
{children}
- {/* Last, so it paints over the content rather than under it. Underneath, anything with a solid
- background — a chart card, a stat tile, a table header — hid it completely, which is how a
- page that did carry a watermark still looked like it did not. At 8% it reads as a wash over
- the page and leaves everything below it legible. */}
+ {/* Last, so it paints over the content. Underneath, any solid background hides it entirely. */}
)
diff --git a/src/components/CippPdf/reportTheme.js b/src/components/CippPdf/reportTheme.js
index 6cc5439c2c13..6e288ae766c7 100644
--- a/src/components/CippPdf/reportTheme.js
+++ b/src/components/CippPdf/reportTheme.js
@@ -300,10 +300,8 @@ export const buildPalette = (branding, { primary, secondary }) => {
* footer might want — `%tenantname%` foremost — is already a CIPP variable, so it is not restated
* here. The `report` prefix keeps these clear of the reserved names in Get-CIPPTextReplacement.
*
- * A PDF is rendered in the browser, so Get-CIPPTextReplacement never sees this text and cannot fill
- * CIPP's own variables in it. `useReportVariables` reads their resolved values back out of
- * ListCustomVariables and hands them to the report, which is what makes `%cippurl%` in a footer
- * print the URL rather than the word. Being a CIPP variable is about where it is documented and
+ * A PDF renders in the browser, so Get-CIPPTextReplacement never sees this text. `useReportVariables`
+ * supplies the resolved values instead. Being a CIPP variable is about where it is documented and
* offered, not about who substitutes it.
*/
export const REPORT_VARIABLES = [
@@ -318,9 +316,8 @@ export const REPORT_VARIABLES = [
* and an unknown token is left as written rather than blanked — that is what tells whoever
* configured it that they mistyped, instead of silently swallowing it.
*
- * This runs in the browser because that is where the PDF is rendered, so it is given the values
- * rather than looking them up: the report's own tokens plus whatever `useReportVariables` resolved
- * out of CIPP for the tenant.
+ * Given the values rather than looking them up: the report's own tokens plus whatever
+ * `useReportVariables` resolved for the tenant.
*/
export const applyReportVariables = (template, variables = {}) => {
if (!template) return ''
diff --git a/src/components/CippPdf/useBrandingSettings.js b/src/components/CippPdf/useBrandingSettings.js
index 45006095b7ba..ff62ffded424 100644
--- a/src/components/CippPdf/useBrandingSettings.js
+++ b/src/components/CippPdf/useBrandingSettings.js
@@ -3,11 +3,8 @@ import { ApiGetCall } from '../../api/ApiCall'
import { DEFAULT_COVER_STOCK } from './resolveCoverImage'
/**
- * The branding a report is drawn with, before any preset is applied.
- *
- * What every consumer sees when branding has not loaded yet — or cannot be read at all. A report
- * rendered in CIPP's own colours is worth far more than one that fails to render, so nothing here
- * waits on the fetch.
+ * Branding used before the fetch lands, or when it cannot be read at all. Reports render in
+ * CIPP's own colours rather than waiting.
*/
export const DEFAULT_BRANDING = Object.freeze({
colour: '#F77F00',
@@ -32,32 +29,20 @@ export const DEFAULT_BRANDING = Object.freeze({
})
/**
- * The react-query keys branding is cached under. Invalidate both after any branding write —
- * `relatedQueryKeys: ['BrandingSettings*']` covers them with one entry.
- *
- * Two keys because they are two different payloads: the gallery form carries every uploaded logo
- * and cover inline, which is megabytes, and only the settings page needs it.
+ * Cache keys for the two payloads: with and without the upload galleries. Invalidate both after a
+ * branding write with `relatedQueryKeys: ['BrandingSettings*']`.
*/
export const BRANDING_QUERY_KEY = 'BrandingSettings'
export const BRANDING_GALLERY_QUERY_KEY = 'BrandingSettings-gallery'
/**
- * Read the report branding.
- *
- * Branding used to live on `useSettings().customBranding`, filled in by ListUserSettings. That put
- * every uploaded cover image — inline base64 data URLs, megabytes of them — into a response fetched
- * on every page load, for the benefit of the handful of screens that draw a PDF. It also meant the
- * branding a report used and the branding the settings page was editing were the same mutable blob
- * of client state, kept in step by an effect.
- *
- * Now it is a request, made by the components that need it, cached by react-query and shared
- * between them. `relatedQueryKeys: [BRANDING_QUERY_KEY]` on a write is what refreshes it.
+ * Read the report branding. Replaces `useSettings().customBranding`, which carried inline images
+ * on every page load and kept report and settings state in the same mutable blob.
*/
export const useBrandingSettings = ({ waiting = true, includeGallery = false } = {}) => {
const branding = ApiGetCall({
url: '/api/ListBrandingSettings',
- // Only the settings page asks for the galleries. A report needs the logo and cover that are
- // selected, and those come back either way.
+ // Only the settings page needs the galleries; the selected logo and cover come back either way.
data: includeGallery ? { includeGallery: true } : undefined,
queryKey: includeGallery ? BRANDING_GALLERY_QUERY_KEY : BRANDING_QUERY_KEY,
waiting,
@@ -65,7 +50,7 @@ export const useBrandingSettings = ({ waiting = true, includeGallery = false } =
return useMemo(() => {
const data = branding.data
- // The endpoint answers 200 with no body when branding cannot be read, so the app still renders.
+ // The endpoint answers 200 with no body when branding cannot be read.
if (!data || typeof data !== 'object' || Array.isArray(data)) return DEFAULT_BRANDING
return data
}, [branding.data])
diff --git a/src/components/CippPdf/useReportVariables.js b/src/components/CippPdf/useReportVariables.js
index b0350f70df55..8d19f6979037 100644
--- a/src/components/CippPdf/useReportVariables.js
+++ b/src/components/CippPdf/useReportVariables.js
@@ -6,20 +6,12 @@ import { useSettings } from '../../hooks/use-settings'
const EMPTY = {}
/**
- * The resolved values of every CIPP variable, for substitution into report footers and watermarks.
+ * Resolved values of every CIPP variable, for substitution into report footers and watermarks by
+ * `applyReportVariables`. A PDF renders in the browser and never passes through
+ * Get-CIPPTextReplacement, so the values are read from ListCustomVariables instead.
*
- * A report's footer is written by an operator on the branding page, where the `%` picker offers the
- * whole CIPP variable vocabulary — `%cippurl%`, `%tenantid%`, custom variables, all of it. Those are
- * normally filled in by Get-CIPPTextReplacement on the server, but a PDF is rendered in the browser
- * and never passes through it, so a footer that used anything beyond the report's own tokens shipped
- * with the token still written in it.
- *
- * This is the missing half: the values come from ListCustomVariables, which resolves them for the
- * tenant, and `applyReportVariables` does the substitution at render time.
- *
- * Fetched here rather than inside the document because a report is rendered by react-pdf's own
- * reconciler, outside the React tree — there is no query client in there to hook into. The values
- * have to arrive as data.
+ * Fetched by the caller rather than inside the document: react-pdf renders through its own
+ * reconciler, outside the React tree, where there is no query client.
*/
export const useReportVariables = (tenantFilter) => {
const currentTenant = useSettings()?.currentTenant
@@ -42,9 +34,8 @@ export const useReportVariables = (tenantFilter) => {
const resolved = {}
for (const variable of results) {
- // A variable with no value is one CIPP cannot fill — the system tokens expanded on an
- // endpoint, mainly. Leaving it out means the token stays written in the footer, which is what
- // tells whoever configured it that it does not resolve here.
+ // Valueless variables (mostly system tokens, expanded on an endpoint) are left out, so the
+ // token stays written in the footer rather than resolving to nothing.
if (variable?.Name && variable.Value !== null && variable.Value !== undefined && variable.Value !== '') {
resolved[variable.Name] = variable.Value
}
diff --git a/src/components/CippSettings/CippBrandingSettings.jsx b/src/components/CippSettings/CippBrandingSettings.jsx
index de3f9f454e69..c798c9afbcf0 100644
--- a/src/components/CippSettings/CippBrandingSettings.jsx
+++ b/src/components/CippSettings/CippBrandingSettings.jsx
@@ -222,9 +222,8 @@ const GalleryTile = ({
const CippBrandingSettings = () => {
const settings = useSettings();
- // Read through ApiGetCall rather than useBrandingSettings so this page can see when the fetch
- // landed: the sync effect below has to run on a *new* server payload, not on every render.
- // Same url and queryKey, so it is the same cache entry every report reads.
+ // Read through ApiGetCall rather than useBrandingSettings so the sync effect below can key on
+ // when the fetch landed. Same cache entry either way.
const brandingQuery = ApiGetCall({
url: "/api/ListBrandingSettings",
data: { includeGallery: true },
@@ -415,10 +414,6 @@ const CippBrandingSettings = () => {
if (coversHydrated || logosHydrated) {
setCoversReady(true);
}
- // Branding used to be a mutable client blob on the settings object, so this had to list every
- // field that might have changed underneath it — and compare the arrays by hand, because their
- // identity changed on every render. A query has one answer to "is this a new payload from the
- // server", which is the only question this effect was ever asking.
// eslint-disable-next-line react-hooks/exhaustive-deps -- sync when server branding payload changes
}, [activePresetId, uploadPending, brandingQuery.isSuccess, brandingQuery.dataUpdatedAt]);
diff --git a/src/components/CippWizard/CippSAMDeploy.jsx b/src/components/CippWizard/CippSAMDeploy.jsx
index d38d0f66ddf2..8507fac4881a 100644
--- a/src/components/CippWizard/CippSAMDeploy.jsx
+++ b/src/components/CippWizard/CippSAMDeploy.jsx
@@ -100,6 +100,15 @@ export const CippSAMDeploy = (props) => {
Multi-factor authentication enabled for the CIPP Service Account, with no trusted
locations or other exclusions.
+
+ Device code sign-in permitted in your partner tenant. Security defaults and Conditional
+ Access authentication flow policies can block it, which will stop this step from
+ completing.
+
+
+
+ This step only creates the CIPP-SAM application registration. The token CIPP runs on is
+ created by the sign-in on the next step.
{authStatus.error && (
diff --git a/src/components/CippWizard/CippTenantModeDeploy.jsx b/src/components/CippWizard/CippTenantModeDeploy.jsx
index d0736b8c5c2e..b31df79683d6 100644
--- a/src/components/CippWizard/CippTenantModeDeploy.jsx
+++ b/src/components/CippWizard/CippTenantModeDeploy.jsx
@@ -1,5 +1,6 @@
import { useEffect } from "react";
import {
+ Alert,
Stack,
Box,
Typography,
@@ -35,6 +36,33 @@ export const CippTenantModeDeploy = (props) => {
waiting: true,
});
+ // The application step mints a client secret and this step uses it moments later, but Entra
+ // can take minutes to activate a new secret. Poll until it is usable so the wait happens
+ // here, rather than the sign-in appearing to work and then failing on the token exchange
+ // with an "invalid client secret" that looks like the app was created wrong.
+ const samSecret = ApiGetCall({
+ url: `/api/ExecSamSecretStatus`,
+ queryKey: "samSecretStatus",
+ waiting: true,
+ staleTime: 0,
+ });
+ const samSecretReady = samSecret.data?.ready === true;
+ const samSecretPropagating = samSecret.data?.reason === "propagating";
+ const {
+ isSuccess: samSecretLoaded,
+ dataUpdatedAt: samSecretUpdatedAt,
+ refetch: refetchSamSecret,
+ } = samSecret;
+
+ // Re-check on a timer rather than a fixed refetchInterval so polling stops once the secret
+ // is usable - there is nothing left to wait for at that point.
+ useEffect(() => {
+ if (samSecretLoaded && !samSecretReady) {
+ const timer = setTimeout(() => refetchSamSecret(), 15000);
+ return () => clearTimeout(timer);
+ }
+ }, [samSecretLoaded, samSecretUpdatedAt, samSecretReady, refetchSamSecret]);
+
useEffect(() => {
if (updateRefreshToken.isSuccess) {
formControl.setValue("GDAPAuth", true);
@@ -201,8 +229,24 @@ export const CippTenantModeDeploy = (props) => {
)}
+ {samSecretLoaded && !samSecretReady && (
+
+ {samSecretPropagating ? (
+ <>
+ Waiting for Microsoft to activate the application secret created in the previous
+ step. Signing in before it is active fails with an invalid client secret error, so
+ this step unlocks on its own once it is ready - usually within a few minutes.
+ Nothing needs to be recreated.
+ >
+ ) : (
+ samSecret.data?.message
+ )}
+
+ )}
+
{
const updatedTokenData = {
...tokenData,
diff --git a/src/components/ReleaseNotesDialog.js b/src/components/ReleaseNotesDialog.js
index dbdf649ed410..1e7f3e166e1c 100644
--- a/src/components/ReleaseNotesDialog.js
+++ b/src/components/ReleaseNotesDialog.js
@@ -79,16 +79,22 @@ const deleteCookie = (name) => {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax;${secureFlag()}`
}
+// Hotfix and maintenance builds publish their own GitHub release (v10.8.1, v10.8.2, ...), so the
+// running build's exact tag is both what we show and what we remember as dismissed. Collapsing
+// patch releases back to vX.Y.0 here left the dismissal cookie - which stores the tag that was
+// actually released - permanently unmatchable, so the dialog reopened on every page load.
+// baseTag survives only as a display fallback for builds whose exact tag has no release
+// (nightly, local, or a version bumped ahead of the tag being published).
const buildReleaseMetadata = (version) => {
- const [major = '0', minor = '0', patch = '0'] = String(version).split('.')
+ const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(version ?? ''))
+ const [major, minor, patch] = match ? match.slice(1) : ['0', '0', '0']
const currentTag = `v${major}.${minor}.${patch}`
- const baseTag = `v${major}.${minor}.0`
- const tagToUse = patch === '0' ? currentTag : baseTag
return {
currentTag,
- releaseTag: tagToUse,
- releaseUrl: `https://github.com/${RELEASE_OWNER}/${RELEASE_REPO}/releases/tag/${tagToUse}`,
+ baseTag: `v${major}.${minor}.0`,
+ releaseTag: currentTag,
+ releaseUrl: `https://github.com/${RELEASE_OWNER}/${RELEASE_REPO}/releases/tag/${currentTag}`,
}
}
@@ -194,12 +200,13 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => {
if (!hasSelected) {
const fallbackRelease =
releaseCatalog.find((release) => release.releaseTag === releaseMeta.releaseTag) ||
+ releaseCatalog.find((release) => release.releaseTag === releaseMeta.baseTag) ||
releaseCatalog[0]
if (fallbackRelease) {
setSelectedReleaseTag(fallbackRelease.releaseTag)
}
}
- }, [releaseCatalog, selectedReleaseTag, releaseMeta.releaseTag])
+ }, [releaseCatalog, selectedReleaseTag, releaseMeta])
const releaseOptions = useMemo(() => {
const mapped = releaseCatalog.map((release) => {
@@ -267,15 +274,17 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => {
return (
releaseCatalog.find((release) => release.releaseTag === selectedReleaseTag) ||
releaseCatalog.find((release) => release.releaseTag === releaseMeta.releaseTag) ||
+ releaseCatalog.find((release) => release.releaseTag === releaseMeta.baseTag) ||
null
)
- }, [releaseCatalog, selectedReleaseTag, releaseMeta.releaseTag])
+ }, [releaseCatalog, selectedReleaseTag, releaseMeta])
const handleDismissUntilNextRelease = () => {
- const newestRelease = releaseCatalog[0]
- const tagToStore = newestRelease?.releaseTag ?? newestRelease?.tagName ?? releaseMeta.releaseTag
+ // Store the same tag the eligibility check reads back - the tag of the build being run, not
+ // the newest tag on GitHub. Those differ for anyone not on the very latest release, and a
+ // cookie that can never match means "don't show until next release" never suppresses anything.
window.localStorage.removeItem(RELEASE_PERMANENT_HIDE_KEY)
- setCookie(RELEASE_COOKIE_KEY, tagToStore)
+ setCookie(RELEASE_COOKIE_KEY, releaseMeta.releaseTag)
setOpen(false)
setIsExpanded(false)
setManualOpenRequested(false)
diff --git a/src/contexts/settings-context.js b/src/contexts/settings-context.js
index e285284317bf..c4356e8187b3 100644
--- a/src/contexts/settings-context.js
+++ b/src/contexts/settings-context.js
@@ -64,10 +64,9 @@ const deleteSettings = () => {
};
/**
- * Branding is no longer client settings — it is a request, cached by react-query under
- * `BRANDING_QUERY_KEY` and read via `useBrandingSettings`. Anything a previous version of CIPP
- * persisted here is dropped on load rather than migrated: it is a stale copy of server state, and
- * its image payloads are what used to blow the localStorage quota once covers were uploaded.
+ * Branding is server state now, read via `useBrandingSettings`. Anything a previous version
+ * persisted here is dropped on load rather than migrated - it is a stale copy, and its image
+ * payloads used to exhaust the localStorage quota.
*/
const stripPersistedBrandingBlobs = (settings) => {
if (!settings || typeof settings !== "object" || !("customBranding" in settings)) {
diff --git a/src/pages/cipp/settings/partner-webhooks.js b/src/pages/cipp/settings/partner-webhooks.js
index 38d681883a1e..e5ae63287d29 100644
--- a/src/pages/cipp/settings/partner-webhooks.js
+++ b/src/pages/cipp/settings/partner-webhooks.js
@@ -45,6 +45,10 @@ const Page = () => {
const subscription = listSubscription?.data?.Results;
const expectedWebhookUrl = subscription?.expectedWebhookUrl;
+ // The backend resolves the expected URL from the custom domain bound to the instance, not from
+ // the host this page was loaded on, so surface which one it picked when there is more than one.
+ const customDomains = subscription?.customDomains ?? [];
+ const hasMultipleCustomDomains = customDomains.length > 1;
// Compared case-insensitively to match the backend, which uses PowerShell's -ne
const webhookUrlIsStale =
!!expectedWebhookUrl &&
@@ -180,11 +184,19 @@ const Page = () => {
{webhookUrlIsStale && (
- This subscription points at a different URL than the one you are using now.
- Save the settings below to re-register it against{" "}
+ This subscription points at a different URL than the one this instance is
+ published on. Save the settings below to re-register it against{" "}
{expectedWebhookUrl}.
)}
+ {hasMultipleCustomDomains && (
+
+ This instance has {customDomains.length} custom domains bound (
+ {customDomains.join(", ")}). CIPP uses the first one,{" "}
+ {subscription?.instanceHostname}, for webhook registrations
+ and notification links.
+
+ )}
),
sx: { pl: 0 },
diff --git a/src/pages/identity/reports/inactive-users-report/index.js b/src/pages/identity/reports/inactive-users-report/index.js
index 8e8e7a0edc50..3ed0ef6c286f 100644
--- a/src/pages/identity/reports/inactive-users-report/index.js
+++ b/src/pages/identity/reports/inactive-users-report/index.js
@@ -20,14 +20,14 @@ const Page = () => {
const actions = [
{
label: "View User",
- link: "/identity/administration/users/user?userId=[azureAdUserId]",
+ link: "/identity/administration/users/user?userId=[azureAdUserId]&tenantFilter=[tenantId]",
multiPost: false,
icon: ,
color: "success",
},
{
label: "Edit User",
- link: "/identity/administration/users/user/edit?userId=[azureAdUserId]",
+ link: "/identity/administration/users/user/edit?userId=[azureAdUserId]&tenantFilter=[tenantId]",
icon: ,
color: "success",
target: "_self",
diff --git a/src/pages/tenant/administration/tenants/index.js b/src/pages/tenant/administration/tenants/index.js
index b6539a5ad323..9eb110355cd6 100644
--- a/src/pages/tenant/administration/tenants/index.js
+++ b/src/pages/tenant/administration/tenants/index.js
@@ -10,6 +10,7 @@ const Page = () => {
const simpleColumns = [
"displayName",
"defaultDomainName",
+ "tenantGroups",
"portal_m365",
"portal_exchange",
"portal_entra",
diff --git a/src/pages/tenant/baselines/alignment/index.js b/src/pages/tenant/baselines/alignment/index.js
index d50f6248e407..a595d8ec2333 100644
--- a/src/pages/tenant/baselines/alignment/index.js
+++ b/src/pages/tenant/baselines/alignment/index.js
@@ -10,6 +10,7 @@ import {
Divider,
Link,
Stack,
+ TextField,
ToggleButton,
ToggleButtonGroup,
Tooltip,
@@ -50,6 +51,7 @@ import {
LayersClear,
PlayArrow,
RemoveCircle,
+ Search,
TaskAlt,
Tune,
Visibility,
@@ -79,6 +81,7 @@ import { useSettings } from '../../../../hooks/use-settings'
import { ApiGetCall } from '../../../../api/ApiCall'
import { parseCippDate } from '../../../../utils/parse-cipp-date'
import { CippOffCanvas } from '../../../../components/CippComponents/CippOffCanvas'
+import { CippAutoComplete } from '../../../../components/CippComponents/CippAutocomplete'
import CippJsonView from '../../../../components/CippFormPages/CippJSONView'
const deviationColors = {
@@ -281,6 +284,9 @@ const runModeLabels = {
run: 'Full run',
compare: 'Compare',
oneoff: 'One-off remediation',
+ triage: 'Operator action',
+ stage: 'Stage change',
+ delete: 'Deletion',
}
// Timeline dot/chip styling per run outcome, mirroring the manage-tenant history page.
@@ -309,10 +315,61 @@ const outcomeTimeline = {
icon: ,
label: 'Skipped - No License',
},
+ // Operator/system audit events (triage verdicts, overrides, stage changes,
+ // deletions carried out for denied deviations).
+ Accepted: { color: 'info', chipColor: 'info', icon: },
+ 'Property Accepted': { color: 'info', chipColor: 'info', icon: },
+ 'Denied - Remediation Ordered': {
+ color: 'warning',
+ chipColor: 'warning',
+ icon: ,
+ },
+ 'Denied - Delete Ordered': {
+ color: 'warning',
+ chipColor: 'warning',
+ icon: ,
+ },
+ 'Property Denied': {
+ color: 'warning',
+ chipColor: 'warning',
+ icon: ,
+ },
+ 'Triage Cleared': { color: 'grey', chipColor: 'default', icon: },
+ 'Property Triage Cleared': {
+ color: 'grey',
+ chipColor: 'default',
+ icon: ,
+ },
+ 'Task Completed': {
+ color: 'success',
+ chipColor: 'success',
+ icon: ,
+ },
+ 'Override Created': { color: 'info', chipColor: 'info', icon: },
+ 'Override Removed': {
+ color: 'grey',
+ chipColor: 'default',
+ icon: ,
+ },
+ 'Stage Advanced': {
+ color: 'primary',
+ chipColor: 'primary',
+ icon: ,
+ },
+ Deleted: { color: 'error', chipColor: 'error', icon: },
+ 'Delete Failed': {
+ color: 'error',
+ chipColor: 'error',
+ icon: ,
+ },
}
-// One readable sentence per run event for the historic timeline.
+// One readable sentence per run event for the historic timeline. Operator and
+// system events carry their own story in `detail`; run events derive one here.
const historyEventMessage = (event) => {
+ if (event.detail) {
+ return `"${event.standardLabel}" - ${event.detail}`
+ }
switch (event.outcome) {
case 'Remediated':
return `Successfully changed "${event.standardLabel}" to the expected configuration`
@@ -408,18 +465,43 @@ const Page = () => {
const denyPathDialog = useDialog()
const [removeOverrideTarget, setRemoveOverrideTarget] = useState(null)
const removeOverrideDialog = useDialog()
+ // Filtering re-orders the timeline, so expansion state keys on stable event/run
+ // identity rather than render index.
const [expandedEvents, setExpandedEvents] = useState(new Set())
- const toggleEventExpansion = (index) => {
+ const toggleEventExpansion = (eventKey) => {
setExpandedEvents((prev) => {
const next = new Set(prev)
- if (next.has(index)) {
- next.delete(index)
+ if (next.has(eventKey)) {
+ next.delete(eventKey)
+ } else {
+ next.add(eventKey)
+ }
+ return next
+ })
+ }
+ const [expandedRuns, setExpandedRuns] = useState(new Set())
+ const toggleRunExpansion = (runKey) => {
+ setExpandedRuns((prev) => {
+ const next = new Set(prev)
+ if (next.has(runKey)) {
+ next.delete(runKey)
} else {
- next.add(index)
+ next.add(runKey)
}
return next
})
}
+ const [historyFilters, setHistoryFilters] = useState({
+ standard: [],
+ outcome: [],
+ mode: [],
+ search: '',
+ })
+ const [historyLimit, setHistoryLimit] = useState(50)
+ const setHistoryFilter = (name, value) => {
+ setHistoryFilters((prev) => ({ ...prev, [name]: value }))
+ setHistoryLimit(50)
+ }
const isTenantView = viewMode === 'tenant'
const isTemplateView = viewMode === 'template'
@@ -1515,14 +1597,8 @@ const Page = () => {
{
{run.triggeredBy}
{run.remediated ? ', remediated' : ''}
+ {run.detail && (
+
+ {run.detail}
+
+ )}
))}
+ }
+ sx={{ alignSelf: 'flex-start' }}
+ onClick={() => {
+ setHistoryFilters({
+ standard: row.standardLabel ? [row.standardLabel] : [],
+ outcome: [],
+ mode: [],
+ search: '',
+ })
+ setHistoryLimit(50)
+ setViewMode('history')
+ }}
+ >
+ View full history
+
)
@@ -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) => {