diff --git a/.github/workflows/auto_comments.yml b/.github/workflows/auto_comments.yml index 9a7664c9007c..89a9908a6748 100644 --- a/.github/workflows/auto_comments.yml +++ b/.github/workflows/auto_comments.yml @@ -17,7 +17,7 @@ jobs: # 1) If the comment includes '!notasponsor', delete it using GitHub Script - name: Delete !notasponsor comment if: contains(github.event.comment.body, '!notasponsor') - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/pr_check.yml b/.github/workflows/pr_check.yml index 15b52ecd4e82..ea36ac1e82f7 100644 --- a/.github/workflows/pr_check.yml +++ b/.github/workflows/pr_check.yml @@ -25,7 +25,7 @@ jobs: github.event.pull_request.head.repo.fork == true && ((github.event.pull_request.head.ref == 'main' || github.event.pull_request.head.ref == 'master') || (github.event.pull_request.base.ref == 'main' || github.event.pull_request.base.ref == 'master')) - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.vscode/launch.json b/.vscode/launch.json index 647f3571b4c2..3622dc676b7c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -35,6 +35,13 @@ "cwd": "${cwd}", "script": ". '${cwd}\\Tools\\Start-CIPPDevEmulators.ps1'" }, + { + "type": "PowerShell", + "name": "Launch in Windows Terminal with Offloading Proc and HTTP only workers", + "request": "launch", + "cwd": "${cwd}", + "script": ". '${cwd}\\Tools\\Start-CippOffloadSimulation.ps1'" + }, { "type": "PowerShell", "name": "Launch in Kitty Terminal", diff --git a/Tests/Shapes/ListTests.json b/Tests/Shapes/ListTests.json index 7dd16b29e91d..a4553bd14c70 100644 --- a/Tests/Shapes/ListTests.json +++ b/Tests/Shapes/ListTests.json @@ -103,23 +103,19 @@ "ExoAcceptedDomains": "number", "ExoAdminAuditLogConfig": "number", "ExoAntiPhishPolicies": "number", - "ExoAntiPhishPolicy": "number", "ExoAntiPhishRules": "number", "ExoAtpPolicyForO365": "number", "ExoDkimSigningConfig": "number", "ExoHostedContentFilterPolicy": "number", "ExoHostedOutboundSpamFilterPolicy": "number", "ExoMalwareFilterPolicies": "number", - "ExoMalwareFilterPolicy": "number", "ExoMalwareFilterRules": "number", "ExoOrganizationConfig": "number", "ExoQuarantinePolicy": "number", "ExoRemoteDomain": "number", "ExoSafeAttachmentPolicies": "number", - "ExoSafeAttachmentPolicy": "number", "ExoSafeAttachmentRules": "number", "ExoSafeLinksPolicies": "number", - "ExoSafeLinksPolicy": "number", "ExoSafeLinksRules": "number", "ExoSharingPolicy": "number", "ExoTenantAllowBlockList": "number", diff --git a/Tools/Start-CippDevEmulators.ps1 b/Tools/Start-CippDevEmulators.ps1 index e0d0146f3ab6..ecf6855fe1e5 100644 --- a/Tools/Start-CippDevEmulators.ps1 +++ b/Tools/Start-CippDevEmulators.ps1 @@ -18,9 +18,35 @@ Write-Host 'Starting emulators...' -ForegroundColor Cyan # Build commands with error handling $azuriteCommand = 'try { azurite } catch { Write-Error $_.Exception.Message } finally { Read-Host "Press Enter to exit" }' -$apiCommand = 'try { func start } catch { Write-Error $_.Exception.Message } finally { Read-Host "Press Enter to exit" }' +$apiCommand = @' +try { + # Use a stable local identity so timer node selection treats this as the catch-all host. + $env:WEBSITE_SITE_NAME = "cipp" + $env:CIPP_PROCESSOR = "false" + $env:AzureFunctionsWebHost__hostid = "cipp-single" + + # Ensure prior offload simulation env overrides do not disable triggers in this shell. + Remove-Item -Path "Env:AzureWebJobs.CIPPTimer.Disabled" -ErrorAction SilentlyContinue + Remove-Item -Path "Env:AzureWebJobs.CIPPActivityFunction.Disabled" -ErrorAction SilentlyContinue + Remove-Item -Path "Env:AzureWebJobs.CIPPOrchestrator.Disabled" -ErrorAction SilentlyContinue + Remove-Item -Path "Env:AzureWebJobs.CIPPQueueTrigger.Disabled" -ErrorAction SilentlyContinue + Remove-Item -Path "Env:AzureWebJobs.CIPPHttpTrigger.Disabled" -ErrorAction SilentlyContinue + + func start +} catch { + Write-Error $_.Exception.Message +} finally { + Read-Host "Press Enter to exit" +} +'@ $frontendCommand = 'try { npm run dev } catch { Write-Error $_.Exception.Message } finally { Read-Host "Press Enter to exit" }' $swaCommand = 'try { npm run start-swa } catch { Write-Error $_.Exception.Message } finally { Read-Host "Press Enter to exit" }' +# Encode commands to avoid parsing issues with multi-line strings +$azuriteEncoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($azuriteCommand)) +$apiEncoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($apiCommand)) +$frontendEncoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($frontendCommand)) +$swaEncoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($swaCommand)) + # Start Windows Terminal with all tabs -wt --title CIPP`; new-tab --title 'Azurite' -d $Path pwsh -c $azuriteCommand`; new-tab --title 'FunctionApp' -d $ApiPath pwsh -c $apiCommand`; new-tab --title 'CIPP Frontend' -d $FrontendPath pwsh -c $frontendCommand`; new-tab --title 'SWA' -d $FrontendPath pwsh -c $swaCommand +wt --title CIPP`; new-tab --title 'Azurite' -d $Path pwsh -EncodedCommand $azuriteEncoded`; new-tab --title 'FunctionApp' -d $ApiPath pwsh -EncodedCommand $apiEncoded`; new-tab --title 'CIPP Frontend' -d $FrontendPath pwsh -EncodedCommand $frontendEncoded`; new-tab --title 'SWA' -d $FrontendPath pwsh -EncodedCommand $swaEncoded diff --git a/Tools/Start-CippOffloadSimulation.ps1 b/Tools/Start-CippOffloadSimulation.ps1 new file mode 100644 index 000000000000..594f271ad305 --- /dev/null +++ b/Tools/Start-CippOffloadSimulation.ps1 @@ -0,0 +1,83 @@ +[CmdletBinding()] +param( + [int]$HttpPort = 7071, + [int]$ProcessorPort = 7072, + [switch]$NoFrontend +) + +Write-Host 'Starting CIPP local offload simulation' -ForegroundColor Cyan + +# Verify Windows Terminal is available +Get-Command wt -ErrorAction Stop | Out-Null + +# Stop any existing node processes +Get-Process node -ErrorAction SilentlyContinue | Stop-Process -ErrorAction SilentlyContinue + +# Run installation script to ensure dependencies are installed and updated before starting emulators +pwsh -File (Join-Path $PSScriptRoot 'Start-CippDevInstallation.ps1') +Write-Host 'Starting emulators...' -ForegroundColor Cyan + +$repoRoot = (Get-Item $PSScriptRoot).Parent.Parent.FullName +$apiPath = Join-Path -Path $repoRoot -ChildPath 'CIPP-API' +$frontendPath = Join-Path -Path $repoRoot -ChildPath 'CIPP' + +if (-not (Test-Path $apiPath)) { + throw "CIPP-API path not found: $apiPath" +} + +$azuriteCommand = 'try { azurite } catch { Write-Error $_.Exception.Message } finally { Read-Host "Press Enter to exit" }' + +$httpHostCommand = @' +try { + $env:WEBSITE_SITE_NAME = "cipp-http" + $env:CIPP_PROCESSOR = "false" + $env:AzureFunctionsWebHost__hostid = "cipp-http" + + Set-Item -Path "Env:AzureWebJobs.CIPPTimer.Disabled" -Value "1" + Set-Item -Path "Env:AzureWebJobs.CIPPActivityFunction.Disabled" -Value "1" + Set-Item -Path "Env:AzureWebJobs.CIPPOrchestrator.Disabled" -Value "1" + Set-Item -Path "Env:AzureWebJobs.CIPPQueueTrigger.Disabled" -Value "1" + + func start --port {HTTP_PORT} +} catch { + Write-Error $_.Exception.Message +} finally { + Read-Host "Press Enter to exit" +} +'@ + +$processorHostCommand = @' +try { + $env:WEBSITE_SITE_NAME = "cipp-proc" + $env:CIPP_PROCESSOR = "true" + $env:AzureFunctionsWebHost__hostid = "cipp-proc" + + Set-Item -Path "Env:AzureWebJobs.CIPPHttpTrigger.Disabled" -Value "1" + + func start --port {PROCESSOR_PORT} +} catch { + Write-Error $_.Exception.Message +} finally { + Read-Host "Press Enter to exit" +} +'@ + +$httpHostCommand = $httpHostCommand.Replace('{HTTP_PORT}', $HttpPort.ToString()) +$processorHostCommand = $processorHostCommand.Replace('{PROCESSOR_PORT}', $ProcessorPort.ToString()) + +$frontendCommand = 'try { npm run dev } catch { Write-Error $_.Exception.Message } finally { Read-Host "Press Enter to exit" }' +$swaCommand = 'try { npm run start-swa } catch { Write-Error $_.Exception.Message } finally { Read-Host "Press Enter to exit" }' + +$azuriteEncoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($azuriteCommand)) +$httpHostEncoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($httpHostCommand)) +$processorHostEncoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($processorHostCommand)) +$frontendEncoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($frontendCommand)) +$swaEncoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($swaCommand)) + +if ($NoFrontend) { + wt --title CIPP-Offload-Sim`; new-tab --title 'Azurite' -d $repoRoot pwsh -EncodedCommand $azuriteEncoded`; new-tab --title 'FunctionApp-HTTP' -d $apiPath pwsh -EncodedCommand $httpHostEncoded`; new-tab --title 'FunctionApp-Processor' -d $apiPath pwsh -EncodedCommand $processorHostEncoded +} else { + wt --title CIPP-Offload-Sim`; new-tab --title 'Azurite' -d $repoRoot pwsh -EncodedCommand $azuriteEncoded`; new-tab --title 'FunctionApp-HTTP' -d $apiPath pwsh -EncodedCommand $httpHostEncoded`; new-tab --title 'FunctionApp-Processor' -d $apiPath pwsh -EncodedCommand $processorHostEncoded`; new-tab --title 'CIPP Frontend' -d $frontendPath pwsh -EncodedCommand $frontendEncoded`; new-tab --title 'SWA' -d $frontendPath pwsh -EncodedCommand $swaEncoded +} + +Write-Host "Started offload simulation tabs (HTTP:$HttpPort, Processor:$ProcessorPort)." -ForegroundColor Green diff --git a/package.json b/package.json index 35da2539bbdb..9ec2d301b018 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cipp", - "version": "10.3.1", + "version": "10.4.0", "author": "CIPP Contributors", "homepage": "https://cipp.app/", "bugs": { @@ -31,11 +31,11 @@ "@emotion/styled": "11.14.1", "@heroicons/react": "2.2.0", "@monaco-editor/react": "^4.6.0", - "@mui/icons-material": "7.3.7", + "@mui/icons-material": "7.3.10", "@mui/lab": "7.0.0-beta.17", - "@mui/material": "7.3.7", - "@mui/system": "7.3.2", - "@mui/x-date-pickers": "^8.27.2", + "@mui/material": "7.3.10", + "@mui/system": "7.3.10", + "@mui/x-date-pickers": "^9.0.2", "@musement/iso-duration": "^1.0.0", "@nivo/core": "^0.99.0", "@nivo/sankey": "^0.99.0", @@ -51,12 +51,12 @@ "@tiptap/extension-image": "^3.20.5", "@tiptap/extension-table": "^3.19.0", "@tiptap/pm": "^3.22.3", - "@tiptap/react": "^3.4.1", + "@tiptap/react": "^3.20.5", "@tiptap/starter-kit": "^3.20.5", "@uiw/react-json-view": "^2.0.0-alpha.41", "@vvo/tzdb": "^6.198.0", "apexcharts": "5.10.4", - "axios": "1.14.0", + "axios": "1.15.0", "date-fns": "4.1.0", "diff": "^8.0.3", "eml-parse-js": "^1.2.0-beta.0", @@ -100,7 +100,7 @@ "react-redux": "9.2.0", "react-syntax-highlighter": "^16.1.0", "react-time-ago": "^7.3.3", - "react-virtuoso": "^4.18.3", + "react-virtuoso": "^4.18.5", "react-window": "^2.2.7", "recharts": "^3.7.0", "redux": "5.0.1", diff --git a/public/version.json b/public/version.json index 5646fed27b32..ce23da362f89 100644 --- a/public/version.json +++ b/public/version.json @@ -1,3 +1,3 @@ { - "version": "10.3.1" + "version": "10.4.0" } diff --git a/src/components/CippComponents/AppApprovalTemplateForm.jsx b/src/components/CippComponents/AppApprovalTemplateForm.jsx index e58add0e6945..20db0c4c48cd 100644 --- a/src/components/CippComponents/AppApprovalTemplateForm.jsx +++ b/src/components/CippComponents/AppApprovalTemplateForm.jsx @@ -19,9 +19,76 @@ const AppApprovalTemplateForm = ({ refetchKey, hideSubmitButton = false, // New prop to hide the submit button when used in a drawer }) => { + const forbiddenManifestProperties = ["keyCredentials", "passwordCredentials"]; const [selectedPermissionSet, setSelectedPermissionSet] = useState(null); const [permissionsLoaded, setPermissionsLoaded] = useState(false); const [permissionSetDrawerVisible, setPermissionSetDrawerVisible] = useState(false); + const [manifestSanitizeMessage, setManifestSanitizeMessage] = useState(null); + + const getManifestValidationError = (manifest) => { + if (!manifest.displayName) { + return "Application manifest must include a 'displayName' property"; + } + + if (manifest.signInAudience && manifest.signInAudience !== "AzureADMyOrg") { + return "signInAudience must be null, undefined, or 'AzureADMyOrg' for security reasons"; + } + + const presentForbiddenProperties = forbiddenManifestProperties.filter( + (propertyName) => Object.prototype.hasOwnProperty.call(manifest, propertyName) + ); + if (presentForbiddenProperties.length > 0) { + return `Remove unsupported manifest properties: ${presentForbiddenProperties.join(", ")}.`; + } + + return null; + }; + + const handleSanitizeManifest = () => { + const currentManifest = formControl.getValues("applicationManifest"); + + if (!currentManifest) { + setManifestSanitizeMessage({ + severity: "warning", + text: "Paste a manifest first, then use cleanup.", + }); + return; + } + + try { + const parsedManifest = JSON.parse(currentManifest); + const removedProperties = forbiddenManifestProperties.filter((propertyName) => + Object.prototype.hasOwnProperty.call(parsedManifest, propertyName) + ); + + if (removedProperties.length === 0) { + setManifestSanitizeMessage({ + severity: "info", + text: "No forbidden sections found. Your manifest is already clean.", + }); + return; + } + + removedProperties.forEach((propertyName) => { + delete parsedManifest[propertyName]; + }); + + formControl.setValue("applicationManifest", JSON.stringify(parsedManifest, null, 2), { + shouldDirty: true, + shouldValidate: true, + }); + + setManifestSanitizeMessage({ + severity: "success", + text: `Removed forbidden sections: ${removedProperties.join(", ")}.`, + }); + } catch (error) { + setManifestSanitizeMessage({ + severity: "error", + text: "Manifest JSON is invalid. Fix the JSON and try cleanup again.", + }); + } + }; // Watch for app type selection changes const selectedAppType = useWatch({ @@ -40,6 +107,28 @@ const AppApprovalTemplateForm = ({ name: "applicationManifest", }); + const getForbiddenManifestPropertiesPresent = (manifestValue) => { + if (!manifestValue) { + return []; + } + + try { + const manifest = JSON.parse(manifestValue); + return forbiddenManifestProperties.filter((propertyName) => + Object.prototype.hasOwnProperty.call(manifest, propertyName) + ); + } catch { + return []; + } + }; + + const forbiddenPropertiesInCurrentManifest = + selectedAppType === "ApplicationManifest" + ? getForbiddenManifestPropertiesPresent(selectedApplicationManifest) + : []; + const showSanitizeManifestButton = forbiddenPropertiesInCurrentManifest.length > 0; + const isTemplateFormValid = formControl?.formState?.isValid ?? false; + // Watch for app selection changes to update template name const selectedApp = useWatch({ control: formControl?.control, @@ -236,6 +325,22 @@ const AppApprovalTemplateForm = ({ } }, [isEditing, isCopy, templateData]); + useEffect(() => { + if (!formControl) { + return; + } + + formControl.trigger(); + }, [ + formControl, + selectedAppType, + selectedApplicationManifest, + selectedApp, + selectedGalleryTemplate, + selectedPermissionSetValue, + templateData, + ]); + // Handle form submission const handleSubmit = (data) => { let appDisplayName, appId, galleryTemplateId, applicationManifest; @@ -249,11 +354,12 @@ const AppApprovalTemplateForm = ({ try { applicationManifest = JSON.parse(data.applicationManifest); - // Validate signInAudience - only allow null/undefined or "AzureADMyOrg" - if ( - applicationManifest.signInAudience && - applicationManifest.signInAudience !== "AzureADMyOrg" - ) { + const manifestValidationError = getManifestValidationError(applicationManifest); + if (manifestValidationError) { + setManifestSanitizeMessage({ + severity: "error", + text: manifestValidationError, + }); return; // Don't submit if validation fails } @@ -481,24 +587,27 @@ const AppApprovalTemplateForm = ({ validate: (value) => { try { const manifest = JSON.parse(value); - - // Check for minimum required property - if (!manifest.displayName) { - return "Application manifest must include a 'displayName' property"; - } - - // Validate signInAudience if present - if (manifest.signInAudience && manifest.signInAudience !== "AzureADMyOrg") { - return "signInAudience must be null, undefined, or 'AzureADMyOrg' for security reasons"; - } - - return true; + return getManifestValidationError(manifest) ?? true; } catch (e) { return "Invalid JSON format"; } }, }} /> + + {showSanitizeManifestButton && ( + + + + )} + {manifestSanitizeMessage && ( + + {manifestSanitizeMessage.text} + + )} + {isEditing ? "Update Template" : "Create Template"} diff --git a/src/components/CippComponents/CippAppApprovalTemplateDrawer.jsx b/src/components/CippComponents/CippAppApprovalTemplateDrawer.jsx index 95526d194495..5315e080eb5a 100644 --- a/src/components/CippComponents/CippAppApprovalTemplateDrawer.jsx +++ b/src/components/CippComponents/CippAppApprovalTemplateDrawer.jsx @@ -144,7 +144,7 @@ export const CippAppApprovalTemplateDrawer = ({ variant="contained" color="primary" onClick={formControl.handleSubmit(handleSubmit)} - disabled={updatePermissions.isPending} + disabled={updatePermissions.isPending || !formControl.formState.isValid} > {updatePermissions.isPending ? isEditMode diff --git a/src/components/CippComponents/CippAppTemplateDrawer.jsx b/src/components/CippComponents/CippAppTemplateDrawer.jsx index 6be184eebaa6..e9db8701f3d6 100644 --- a/src/components/CippComponents/CippAppTemplateDrawer.jsx +++ b/src/components/CippComponents/CippAppTemplateDrawer.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useCallback, useState } from "react"; +import React, { useEffect, useCallback, useState } from 'react' import { Button, Divider, @@ -10,263 +10,265 @@ import { ListItemText, ListItemSecondaryAction, Alert, -} from "@mui/material"; -import { Grid } from "@mui/system"; -import { useForm, useWatch } from "react-hook-form"; -import { Add, Delete, Edit, Save } from "@mui/icons-material"; -import { CippOffCanvas } from "./CippOffCanvas"; -import CippFormComponent from "./CippFormComponent"; -import { CippFormCondition } from "./CippFormCondition"; -import { CippApiResults } from "./CippApiResults"; -import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; -import languageList from "../../data/languageList.json"; +} from '@mui/material' +import { Grid } from '@mui/system' +import { useForm, useWatch } from 'react-hook-form' +import { Add, Delete, Edit, Save } from '@mui/icons-material' +import { CippOffCanvas } from './CippOffCanvas' +import CippFormComponent from './CippFormComponent' +import { CippFormCondition } from './CippFormCondition' +import { CippApiResults } from './CippApiResults' +import { ApiGetCall, ApiPostCall } from '../../api/ApiCall' +import languageList from '../../data/languageList.json' const appTypeLabels = { - mspApp: "MSP Vendor App", - StoreApp: "Store App", - chocolateyApp: "Chocolatey App", - officeApp: "Microsoft Office", - win32ScriptApp: "Custom Application", -}; + mspApp: 'MSP Vendor App', + StoreApp: 'Store App', + chocolateyApp: 'Chocolatey App', + officeApp: 'Microsoft Office', + win32ScriptApp: 'Custom Application', +} export const CippAppTemplateDrawer = ({ - buttonText = "Create Template", + buttonText = 'Create Template', editData = null, open = false, onClose, }) => { - const [drawerVisible, setDrawerVisible] = useState(false); - const [apps, setApps] = useState([]); - const [editGUID, setEditGUID] = useState(null); - const formControl = useForm({ mode: "onChange" }); - const templateFormControl = useForm({ mode: "onChange" }); + const [drawerVisible, setDrawerVisible] = useState(false) + const [apps, setApps] = useState([]) + const [editGUID, setEditGUID] = useState(null) + const formControl = useForm({ mode: 'onChange' }) + const templateFormControl = useForm({ mode: 'onChange' }) - const [fetchKey, setFetchKey] = useState(null); + const [fetchKey, setFetchKey] = useState(null) useEffect(() => { if (open && editData?.GUID) { - setFetchKey(`AppTemplate-${editData.GUID}-${Date.now()}`); + setFetchKey(`AppTemplate-${editData.GUID}-${Date.now()}`) } - }, [open, editData?.GUID]); + }, [open, editData?.GUID]) const templateFetch = ApiGetCall({ url: editData?.GUID ? `/api/ListAppTemplates?ID=${editData.GUID}` : null, queryKey: fetchKey, waiting: !!(open && editData?.GUID && fetchKey), - }); + }) useEffect(() => { if (open && editData && templateFetch.isSuccess && templateFetch.data) { - const template = Array.isArray(templateFetch.data) ? templateFetch.data[0] : templateFetch.data; - if (!template) return; + const template = Array.isArray(templateFetch.data) + ? templateFetch.data[0] + : templateFetch.data + if (!template) return - setEditGUID(template.GUID || editData.GUID || null); + setEditGUID(template.GUID || editData.GUID || null) templateFormControl.reset({ - templateName: template.displayName || editData.displayName || "", - templateDescription: template.description || editData.description || "", - }); + templateName: template.displayName || editData.displayName || '', + templateDescription: template.description || editData.description || '', + }) - let appsArray = template.Apps || []; - if (typeof appsArray === "string") { + let appsArray = template.Apps || [] + if (typeof appsArray === 'string') { try { - appsArray = JSON.parse(appsArray); + appsArray = JSON.parse(appsArray) } catch { - appsArray = []; + appsArray = [] } } if (!Array.isArray(appsArray)) { - appsArray = []; + appsArray = [] } const loadedApps = appsArray.map((app) => ({ appType: app.appType, appName: app.appName, - config: typeof app.config === "string" ? app.config : JSON.stringify(app.config), - })); - setApps(loadedApps); - setDrawerVisible(true); + config: typeof app.config === 'string' ? app.config : JSON.stringify(app.config), + })) + setApps(loadedApps) + setDrawerVisible(true) } - }, [open, editData, templateFetch.isSuccess, templateFetch.data]); + }, [open, editData, templateFetch.isSuccess, templateFetch.data]) const applicationType = useWatch({ control: formControl.control, - name: "appType", - }); + name: 'appType', + }) const searchQuerySelection = useWatch({ control: formControl.control, - name: "packageSearch", - }); + name: 'packageSearch', + }) const updateSearchSelection = useCallback( (searchQuerySelection) => { if (searchQuerySelection) { - formControl.setValue("packagename", searchQuerySelection.value.packagename); - formControl.setValue("applicationName", searchQuerySelection.value.applicationName); - formControl.setValue("description", searchQuerySelection.value.description); + formControl.setValue('packagename', searchQuerySelection.value.packagename) + formControl.setValue('applicationName', searchQuerySelection.value.applicationName) + formControl.setValue('description', searchQuerySelection.value.description) if (searchQuerySelection.value.customRepo) { - formControl.setValue("customRepo", searchQuerySelection.value.customRepo); + formControl.setValue('customRepo', searchQuerySelection.value.customRepo) } } }, - [formControl.setValue], - ); + [formControl.setValue] + ) useEffect(() => { - updateSearchSelection(searchQuerySelection); - }, [updateSearchSelection, searchQuerySelection]); + updateSearchSelection(searchQuerySelection) + }, [updateSearchSelection, searchQuerySelection]) - const ChocosearchResults = ApiPostCall({ urlFromData: true }); - const winGetSearchResults = ApiPostCall({ urlFromData: true }); + const ChocosearchResults = ApiPostCall({ urlFromData: true }) + const winGetSearchResults = ApiPostCall({ urlFromData: true }) const saveTemplate = ApiPostCall({ urlFromData: true, - relatedQueryKeys: ["ListAppTemplates"], - }); + relatedQueryKeys: ['ListAppTemplates'], + }) const searchApp = (searchText, type) => { - if (type === "choco") { + if (type === 'choco') { ChocosearchResults.mutate({ - url: "/api/ListAppsRepository", + url: '/api/ListAppsRepository', data: { search: searchText }, queryKey: `SearchApp-${searchText}-${type}`, - }); + }) } - if (type === "StoreApp") { + if (type === 'StoreApp') { winGetSearchResults.mutate({ - url: "/api/ListPotentialApps", - data: { searchString: searchText, type: "WinGet" }, + url: '/api/ListPotentialApps', + data: { searchString: searchText, type: 'WinGet' }, queryKey: `SearchApp-${searchText}-${type}`, - }); + }) } - }; + } const getAppName = (formData) => { - const type = formData.appType?.value; - if (type === "mspApp") return formData.displayName || formData.rmmname?.label || "MSP App"; - if (type === "officeApp") return "Microsoft 365 Apps"; - return formData.applicationName || formData.packagename || "Unnamed App"; - }; + const type = formData.appType?.value + if (type === 'mspApp') return formData.displayName || formData.rmmname?.label || 'MSP App' + if (type === 'officeApp') return 'Microsoft 365 Apps' + return formData.applicationName || formData.packagename || 'Unnamed App' + } const handleAddApp = () => { - const formData = formControl.getValues(); - if (!formData.appType?.value) return; + const formData = formControl.getValues() + if (!formData.appType?.value) return const appEntry = { appType: formData.appType.value, appName: getAppName(formData), config: JSON.stringify(formData), - }; + } - setApps((prev) => [...prev, appEntry]); - formControl.reset({ appType: null }); - }; + setApps((prev) => [...prev, appEntry]) + formControl.reset({ appType: null }) + } const handleEditApp = (index) => { - const currentForm = formControl.getValues(); - const appToEdit = apps[index]; + const currentForm = formControl.getValues() + const appToEdit = apps[index] setApps((prev) => { - const updated = [...prev]; + const updated = [...prev] if (currentForm.appType?.value) { updated.push({ appType: currentForm.appType.value, appName: getAppName(currentForm), config: JSON.stringify(currentForm), - }); + }) } - return updated.filter((_, i) => i !== index); - }); + return updated.filter((_, i) => i !== index) + }) - const config = JSON.parse(appToEdit.config); - if (!config.appType || typeof config.appType === "string") { - const typeValue = appToEdit.appType || config.appType; + const config = JSON.parse(appToEdit.config) + if (!config.appType || typeof config.appType === 'string') { + const typeValue = appToEdit.appType || config.appType config.appType = { label: appTypeLabels[typeValue] || typeValue, value: typeValue, - }; + } } // Normalize "Save as Template" configs (IntuneBody format) to form fields if (config.IntuneBody && !config.applicationName) { - const body = config.IntuneBody; - config.applicationName = config.ApplicationName || body.displayName || ""; - config.description = body.description || ""; - config.AssignTo = config.assignTo || "On"; + const body = config.IntuneBody + config.applicationName = config.ApplicationName || body.displayName || '' + config.description = body.description || '' + config.AssignTo = config.assignTo || 'On' // WinGet/Store: packageIdentifier if (body.packageIdentifier) { - config.packagename = body.packageIdentifier; + config.packagename = body.packageIdentifier } // Chocolatey: extract package name from detection rules or install command if (!config.packagename && body.detectionRules?.[0]?.fileOrFolderName) { - config.packagename = body.detectionRules[0].fileOrFolderName; + config.packagename = body.detectionRules[0].fileOrFolderName } if (!config.packagename && body.installCommandLine) { - const match = body.installCommandLine.match(/-Packagename\s+(\S+)/i); - if (match) config.packagename = match[1]; + const match = body.installCommandLine.match(/-Packagename\s+(\S+)/i) + if (match) config.packagename = match[1] } // Chocolatey: custom repo if (body.installCommandLine) { - const repoMatch = body.installCommandLine.match(/-CustomRepo\s+(\S+)/i); - if (repoMatch) config.customRepo = repoMatch[1]; + const repoMatch = body.installCommandLine.match(/-CustomRepo\s+(\S+)/i) + if (repoMatch) config.customRepo = repoMatch[1] } } - formControl.reset({ appType: config.appType }); + formControl.reset({ appType: config.appType }) setTimeout(() => { Object.entries(config).forEach(([key, value]) => { - if (key !== "appType") { - formControl.setValue(key, value); + if (key !== 'appType') { + formControl.setValue(key, value) } - }); - }, 100); - }; + }) + }, 100) + } const handleRemoveApp = (index) => { - setApps((prev) => prev.filter((_, i) => i !== index)); - }; + setApps((prev) => prev.filter((_, i) => i !== index)) + } const getTotalApps = () => { - const currentForm = formControl.getValues(); - const formHasApp = !!currentForm.appType?.value; - return apps.length + (formHasApp ? 1 : 0); - }; + const currentForm = formControl.getValues() + const formHasApp = !!currentForm.appType?.value + return apps.length + (formHasApp ? 1 : 0) + } const handleSaveTemplate = () => { - const templateData = templateFormControl.getValues(); - const currentForm = formControl.getValues(); + const templateData = templateFormControl.getValues() + const currentForm = formControl.getValues() - const allApps = [...apps]; + const allApps = [...apps] if (currentForm.appType?.value) { allApps.push({ appType: currentForm.appType.value, appName: getAppName(currentForm), config: JSON.stringify(currentForm), - }); + }) } - if (!templateData.templateName || allApps.length === 0) return; + if (!templateData.templateName || allApps.length === 0) return const payload = { displayName: templateData.templateName, - description: templateData.templateDescription || "", + description: templateData.templateDescription || '', apps: allApps, - }; + } if (editGUID) { - payload.GUID = editGUID; + payload.GUID = editGUID } saveTemplate.mutate({ - url: "/api/AddAppTemplate", + url: '/api/AddAppTemplate', data: payload, - }); - }; + }) + } const handleClose = () => { - setDrawerVisible(false); - formControl.reset({ appType: null }); - templateFormControl.reset({ templateName: "", templateDescription: "" }); - setApps([]); - setEditGUID(null); - saveTemplate.reset(); - if (onClose) onClose(); - }; + setDrawerVisible(false) + formControl.reset({ appType: null }) + templateFormControl.reset({ templateName: '', templateDescription: '' }) + setApps([]) + setEditGUID(null) + saveTemplate.reset() + if (onClose) onClose() + } return ( <> @@ -276,12 +278,12 @@ export const CippAppTemplateDrawer = ({ )} +
+ + + + + + ) +} const Error500 = (props) => { //when we browse away from the page we want to reset the error boundary //this will prevent the error from showing on other pages - const router = useRouter(); + const router = useRouter() useEffect(() => { return () => { - props.resetErrorBoundary(); - }; - }, [router]); + props.resetErrorBoundary() + } + }, [router]) return ( - <> + ( + + )} + > 500 - Error @@ -26,17 +78,17 @@ const Error500 = (props) => { sx={{ flexGrow: 1, py: 4, - height: "80vh", + height: '80vh', }} > - + { text={ <> Oh no! It seems something went wrong. -
{props.error.message}
+
{props.error?.message}
You can use the button below to try again. } title="Error 500 - Something went wrong" - linkText={"Try again"} + linkText={'Try again'} onButtonClick={() => props.resetErrorBoundary()} />
@@ -59,8 +111,8 @@ const Error500 = (props) => {
- - ); -}; +
+ ) +} -export default Error500; +export default Error500 diff --git a/src/pages/cipp/preferences.js b/src/pages/cipp/preferences.js index f6bbde2e7704..2b5303fa8225 100644 --- a/src/pages/cipp/preferences.js +++ b/src/pages/cipp/preferences.js @@ -183,47 +183,47 @@ const Page = () => { const portalLinksConfig = [ { name: "portalLinks.M365_Portal", - label: "M365 Portal", + label: "M365", }, { name: "portalLinks.Exchange_Portal", - label: "Exchange Portal", + label: "Exchange", }, { name: "portalLinks.Entra_Portal", - label: "Entra Portal", + label: "Entra", }, { name: "portalLinks.Teams_Portal", - label: "Teams Portal", + label: "Teams", }, { name: "portalLinks.Azure_Portal", - label: "Azure Portal", + label: "Azure", }, { name: "portalLinks.Intune_Portal", - label: "Intune Portal", + label: "Intune", }, { name: "portalLinks.SharePoint_Admin", - label: "SharePoint Admin", + label: "SharePoint", }, { name: "portalLinks.Security_Portal", - label: "Security Portal", + label: "Security", }, { name: "portalLinks.Compliance_Portal", - label: "Compliance Portal", + label: "Compliance", }, { name: "portalLinks.Power_Platform_Portal", - label: "Power Platform Portal", + label: "Power Platform", }, { name: "portalLinks.Power_BI_Portal", - label: "Power BI Portal", + label: "Power BI", }, ]; diff --git a/src/pages/email/administration/quarantine/index.js b/src/pages/email/administration/quarantine/index.js index 92aac9f435c0..0aa088af9a58 100644 --- a/src/pages/email/administration/quarantine/index.js +++ b/src/pages/email/administration/quarantine/index.js @@ -126,7 +126,7 @@ const Page = () => { }, confirmText: "Are you sure you want to deny this message?", icon: , - condition: (row) => row.ReleaseStatus !== "DENIED", + condition: (row) => row.ReleaseStatus === "REQUESTED", }, { label: "Release & Allow Sender", @@ -136,6 +136,8 @@ const Page = () => { 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?", diff --git a/src/pages/email/reports/activesync-devices/index.js b/src/pages/email/reports/activesync-devices/index.js index 38b7ea65f87d..4ddd6306cd99 100644 --- a/src/pages/email/reports/activesync-devices/index.js +++ b/src/pages/email/reports/activesync-devices/index.js @@ -76,6 +76,7 @@ const Page = () => { 'firstSyncTime', 'lastSyncAttemptTime', 'lastSuccessSync', + 'syncInfoNote', 'deviceID', ], actions: actions, diff --git a/src/pages/endpoint/autopilot/list-devices/index.js b/src/pages/endpoint/autopilot/list-devices/index.js index 3c25271def7c..c6694e3d1920 100644 --- a/src/pages/endpoint/autopilot/list-devices/index.js +++ b/src/pages/endpoint/autopilot/list-devices/index.js @@ -1,145 +1,151 @@ -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; -import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; -import { CippApiDialog } from "../../../../components/CippComponents/CippApiDialog.jsx"; -import { Button } from "@mui/material"; -import { PersonAdd, Delete, Sync, Add, Edit, Sell } from "@mui/icons-material"; -import { useDialog } from "../../../../hooks/use-dialog"; -import Link from "next/link"; +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' +import { CippApiDialog } from '../../../../components/CippComponents/CippApiDialog.jsx' +import { Button } from '@mui/material' +import { PersonAdd, Delete, Sync, Add, Edit, Sell } from '@mui/icons-material' +import { useDialog } from '../../../../hooks/use-dialog' +import Link from 'next/link' const Page = () => { - const pageTitle = "Autopilot Devices"; - const createDialog = useDialog(); + const pageTitle = 'Autopilot Devices' + const createDialog = useDialog() const actions = [ { - label: "Assign device", + label: 'Assign device', icon: , - type: "POST", - url: "/api/ExecAssignAPDevice", + type: 'POST', + url: '/api/ExecAssignAPDevice', data: { - device: "id", - serialNumber: "serialNumber", + device: 'id', + serialNumber: 'serialNumber', }, - confirmText: "Select the user to assign the device to", + confirmText: 'Select the user to assign the device to', fields: [ { - type: "autoComplete", - name: "user", - label: "Select User", + type: 'autoComplete', + name: 'user', + label: 'Select User', multiple: false, creatable: false, api: { - url: "/api/listUsers", + url: '/api/listUsers', labelField: (user) => `${user.displayName} (${user.userPrincipalName})`, - valueField: "userPrincipalName", + valueField: 'userPrincipalName', addedField: { - userPrincipalName: "userPrincipalName", - addressableUserName: "displayName", + userPrincipalName: 'userPrincipalName', + addressableUserName: 'displayName', }, }, }, ], - color: "info", + color: 'info', }, { - label: "Rename Device", + label: 'Rename Device', icon: , - type: "POST", - url: "/api/ExecRenameAPDevice", + type: 'POST', + url: '/api/ExecRenameAPDevice', data: { - deviceId: "id", - serialNumber: "serialNumber", + deviceId: 'id', + serialNumber: 'serialNumber', }, - confirmText: "Enter the new display name for the device.", + confirmText: 'Enter the new display name for the device.', fields: [ { - type: "textField", - name: "displayName", - label: "New Display Name", + type: 'textField', + name: 'displayName', + label: 'New Display Name', required: true, validate: (value) => { if (!value) { - return "Display name is required."; + return 'Display name is required.' } if (value.length > 15) { - return "Display name must be 15 characters or less."; + return 'Display name must be 15 characters or less.' } if (/\s/.test(value)) { - return "Display name cannot contain spaces."; + return 'Display name cannot contain spaces.' } if (!/^[a-zA-Z0-9-]+$/.test(value)) { - return "Display name can only contain letters, numbers, and hyphens."; + return 'Display name can only contain letters, numbers, and hyphens.' } if (/^[0-9]+$/.test(value)) { - return "Display name cannot contain only numbers."; + return 'Display name cannot contain only numbers.' } - return true; // Indicates validation passed + return true // Indicates validation passed }, }, ], - color: "secondary", + color: 'secondary', }, { - label: "Edit Group Tag", + label: 'Edit Group Tag', icon: , - type: "POST", - url: "/api/ExecSetAPDeviceGroupTag", + type: 'POST', + url: '/api/ExecSetAPDeviceGroupTag', data: { - deviceId: "id", - serialNumber: "serialNumber", + deviceId: 'id', + serialNumber: 'serialNumber', }, - confirmText: "Enter the new group tag for the device.", + confirmText: 'Enter the new group tag for the device.', fields: [ { - type: "textField", - name: "groupTag", - label: "Group Tag", + type: 'textField', + name: 'groupTag', + label: 'Group Tag', validate: (value) => { if (value && value.length > 128) { - return "Group tag cannot exceed 128 characters."; + return 'Group tag cannot exceed 128 characters.' } - return true; // Validation passed + return true // Validation passed }, }, ], - color: "secondary", + color: 'secondary', }, { - label: "Delete Device", + label: 'Delete Device', icon: , - type: "POST", - url: "/api/RemoveAPDevice", - data: { ID: "id" }, - confirmText: "Are you sure you want to delete this device?", - color: "danger", + type: 'POST', + url: '/api/RemoveAPDevice', + data: { ID: 'id' }, + confirmText: 'Are you sure you want to delete this device?', + color: 'danger', }, - ]; + ] const offCanvas = { extendedInfoFields: [ - "userPrincipalName", - "productKey", - "serialNumber", - "model", - "manufacturer", + 'userPrincipalName', + 'productKey', + 'serialNumber', + 'model', + 'manufacturer', ], actions: actions, - }; + } const simpleColumns = [ - "displayName", - "serialNumber", - "model", - "manufacturer", - "groupTag", - "enrollmentState", - ]; + 'Tenant', + 'displayName', + 'serialNumber', + 'model', + 'manufacturer', + 'groupTag', + 'enrollmentState', + ] return ( <> { title="Sync Autopilot Devices" createDialog={createDialog} api={{ - type: "POST", - url: "/api/ExecSyncAPDevices", + type: 'POST', + url: '/api/ExecSyncAPDevices', data: {}, confirmText: - "Are you sure you want to sync Autopilot devices? This can only be done every 10 minutes.", + 'Are you sure you want to sync Autopilot devices? This can only be done every 10 minutes.', }} /> - ); -}; + ) +} -Page.getLayout = (page) => {page}; -export default Page; +Page.getLayout = (page) => {page} +export default Page diff --git a/src/pages/endpoint/autopilot/list-status-pages/index.js b/src/pages/endpoint/autopilot/list-status-pages/index.js index 4ec8a3b93313..affa4e128579 100644 --- a/src/pages/endpoint/autopilot/list-status-pages/index.js +++ b/src/pages/endpoint/autopilot/list-status-pages/index.js @@ -1,26 +1,34 @@ -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; -import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; -import { CippAutopilotStatusPageDrawer } from "../../../../components/CippComponents/CippAutopilotStatusPageDrawer"; +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' +import { CippAutopilotStatusPageDrawer } from '../../../../components/CippComponents/CippAutopilotStatusPageDrawer' const Page = () => { - const pageTitle = "Autopilot Status Pages"; + const pageTitle = 'Autopilot Status Pages' const simpleColumns = [ - "displayName", - "Description", - "installProgressTimeoutInMinutes", - "showInstallationProgress", - "blockDeviceSetupRetryByUser", - "allowDeviceResetOnInstallFailure", - "allowDeviceUseOnInstallFailure", - ]; + 'Tenant', + 'displayName', + 'Description', + 'installProgressTimeoutInMinutes', + 'showInstallationProgress', + 'blockDeviceSetupRetryByUser', + 'allowDeviceResetOnInstallFailure', + 'allowDeviceUseOnInstallFailure', + ] // No actions specified in the original file, so none are included here. return ( @@ -28,8 +36,8 @@ const Page = () => { } /> - ); -}; + ) +} -Page.getLayout = (page) => {page}; -export default Page; +Page.getLayout = (page) => {page} +export default Page diff --git a/src/pages/identity/administration/jit-admin-templates/add.jsx b/src/pages/identity/administration/jit-admin-templates/add.jsx index 895bc63e8332..986e7ea378d1 100644 --- a/src/pages/identity/administration/jit-admin-templates/add.jsx +++ b/src/pages/identity/administration/jit-admin-templates/add.jsx @@ -7,8 +7,10 @@ import CippFormComponent from "../../../../components/CippComponents/CippFormCom import { CippFormCondition } from "../../../../components/CippComponents/CippFormCondition"; import { CippFormDomainSelector } from "../../../../components/CippComponents/CippFormDomainSelector"; import { CippFormUserSelector } from "../../../../components/CippComponents/CippFormUserSelector"; +import { CippFormGroupSelector } from "../../../../components/CippComponents/CippFormGroupSelector"; import gdaproles from "../../../../data/GDAPRoles.json"; import { useSettings } from "../../../../hooks/use-settings"; +import { useEffect } from "react"; const Page = () => { const userSettingsDefaults = useSettings(); @@ -21,6 +23,39 @@ const Page = () => { const watchedTenant = useWatch({ control: formControl.control, name: "tenantFilter" }); const isAllTenants = watchedTenant?.value === "AllTenants" || watchedTenant === "AllTenants"; + const useRoles = useWatch({ control: formControl.control, name: "defaultUseRoles" }); + const useGroups = useWatch({ control: formControl.control, name: "defaultUseGroups" }); + + // Clear fields when switches are toggled off + useEffect(() => { + if (!useRoles) { + formControl.setValue("defaultRoles", []); + } + }, [useRoles]); + + useEffect(() => { + if (!useGroups) { + formControl.setValue("defaultGroups", []); + } + }, [useGroups]); + + // Reset expiration action when switches change + useEffect(() => { + const currentAction = formControl.getValues("defaultExpireAction"); + if (!currentAction?.value) return; + + if (!useRoles && currentAction.value === "RemoveRoles") { + formControl.setValue("defaultExpireAction", null); + } else if (!useGroups && currentAction.value === "RemoveGroups") { + formControl.setValue("defaultExpireAction", null); + } else if ((!useRoles || !useGroups) && currentAction.value === "RemoveRolesAndGroups") { + formControl.setValue("defaultExpireAction", null); + } else if (useRoles && useGroups && currentAction.value === "RemoveRoles") { + formControl.setValue("defaultExpireAction", null); + } else if (useRoles && useGroups && currentAction.value === "RemoveGroups") { + formControl.setValue("defaultExpireAction", null); + } + }, [useRoles, useGroups]); return ( <> @@ -64,26 +99,83 @@ const Page = () => { ({ label: role.Name, value: role.ObjectId }))} + type="switch" + label="Admin Roles" + name="defaultUseRoles" formControl={formControl} - required={true} - validators={{ - required: "At least one default role is required", - validate: (options) => { - if (!options?.length) { - return "At least one default role is required"; - } - return true; - }, - }} /> + {!isAllTenants && ( + + )} + {!useRoles && !useGroups && ( + + Please select at least "Admin Roles" or "Group Membership" + + )} + + + ({ label: role.Name, value: role.ObjectId }))} + formControl={formControl} + required={true} + validators={{ + required: "At least one default role is required", + validate: (options) => { + if (!options?.length) { + return "At least one default role is required"; + } + return true; + }, + }} + /> + + + + {!isAllTenants && ( + + + { + if (!options?.length) { + return "At least one group is required"; + } + return true; + }, + }} + /> + + + )} + { name="defaultExpireAction" multiple={false} creatable={false} - options={[ - { label: "Delete User", value: "DeleteUser" }, - { label: "Disable User", value: "DisableUser" }, - { label: "Remove Roles", value: "RemoveRoles" }, - ]} + options={(() => { + const opts = [ + { label: "Delete User", value: "DeleteUser" }, + { label: "Disable User", value: "DisableUser" }, + ]; + if (useRoles && useGroups) { + opts.push({ label: "Remove Roles and Groups", value: "RemoveRolesAndGroups" }); + } else if (useRoles) { + opts.push({ label: "Remove Roles", value: "RemoveRoles" }); + } else if (useGroups) { + opts.push({ label: "Remove Groups", value: "RemoveGroups" }); + } + return opts; + })()} formControl={formControl} + required={true} + validators={{ required: "Expiration action is required" }} /> diff --git a/src/pages/identity/administration/jit-admin-templates/edit.jsx b/src/pages/identity/administration/jit-admin-templates/edit.jsx index 85bfe891a96f..46641f5f59f9 100644 --- a/src/pages/identity/administration/jit-admin-templates/edit.jsx +++ b/src/pages/identity/administration/jit-admin-templates/edit.jsx @@ -7,6 +7,7 @@ import CippFormComponent from "../../../../components/CippComponents/CippFormCom import { CippFormCondition } from "../../../../components/CippComponents/CippFormCondition"; import { CippFormDomainSelector } from "../../../../components/CippComponents/CippFormDomainSelector"; import { CippFormUserSelector } from "../../../../components/CippComponents/CippFormUserSelector"; +import { CippFormGroupSelector } from "../../../../components/CippComponents/CippFormGroupSelector"; import gdaproles from "../../../../data/GDAPRoles.json"; import { useSettings } from "../../../../hooks/use-settings"; import { useRouter } from "next/router"; @@ -27,6 +28,39 @@ const Page = () => { const watchedTenant = useWatch({ control: formControl.control, name: "tenantFilter" }); const isAllTenants = watchedTenant?.value === "AllTenants" || watchedTenant === "AllTenants"; + const useRoles = useWatch({ control: formControl.control, name: "defaultUseRoles" }); + const useGroups = useWatch({ control: formControl.control, name: "defaultUseGroups" }); + + // Clear fields when switches are toggled off + useEffect(() => { + if (!useRoles) { + formControl.setValue("defaultRoles", []); + } + }, [useRoles]); + + useEffect(() => { + if (!useGroups) { + formControl.setValue("defaultGroups", []); + } + }, [useGroups]); + + // Reset expiration action when switches change + useEffect(() => { + const currentAction = formControl.getValues("defaultExpireAction"); + if (!currentAction?.value) return; + + if (!useRoles && currentAction.value === "RemoveRoles") { + formControl.setValue("defaultExpireAction", null); + } else if (!useGroups && currentAction.value === "RemoveGroups") { + formControl.setValue("defaultExpireAction", null); + } else if ((!useRoles || !useGroups) && currentAction.value === "RemoveRolesAndGroups") { + formControl.setValue("defaultExpireAction", null); + } else if (useRoles && useGroups && currentAction.value === "RemoveRoles") { + formControl.setValue("defaultExpireAction", null); + } else if (useRoles && useGroups && currentAction.value === "RemoveGroups") { + formControl.setValue("defaultExpireAction", null); + } + }, [useRoles, useGroups]); // Get the template data const template = ApiGetCall({ @@ -88,26 +122,83 @@ const Page = () => { ({ label: role.Name, value: role.ObjectId }))} + type="switch" + label="Admin Roles" + name="defaultUseRoles" formControl={formControl} - required={true} - validators={{ - required: "At least one default role is required", - validate: (options) => { - if (!options?.length) { - return "At least one default role is required"; - } - return true; - }, - }} /> + {!isAllTenants && ( + + )} + {!useRoles && !useGroups && ( + + Please select at least "Admin Roles" or "Group Membership" + + )} + + + ({ label: role.Name, value: role.ObjectId }))} + formControl={formControl} + required={true} + validators={{ + required: "At least one default role is required", + validate: (options) => { + if (!options?.length) { + return "At least one default role is required"; + } + return true; + }, + }} + /> + + + + {!isAllTenants && ( + + + { + if (!options?.length) { + return "At least one group is required"; + } + return true; + }, + }} + /> + + + )} + { name="defaultExpireAction" multiple={false} creatable={false} - options={[ - { label: "Delete User", value: "DeleteUser" }, - { label: "Disable User", value: "DisableUser" }, - { label: "Remove Roles", value: "RemoveRoles" }, - ]} + options={(() => { + const opts = [ + { label: "Delete User", value: "DeleteUser" }, + { label: "Disable User", value: "DisableUser" }, + ]; + if (useRoles && useGroups) { + opts.push({ label: "Remove Roles and Groups", value: "RemoveRolesAndGroups" }); + } else if (useRoles) { + opts.push({ label: "Remove Roles", value: "RemoveRoles" }); + } else if (useGroups) { + opts.push({ label: "Remove Groups", value: "RemoveGroups" }); + } + return opts; + })()} formControl={formControl} + required={true} + validators={{ required: "Expiration action is required" }} /> diff --git a/src/pages/identity/administration/jit-admin/add.jsx b/src/pages/identity/administration/jit-admin/add.jsx index 16842bd313f6..d2d3d959d3f0 100644 --- a/src/pages/identity/administration/jit-admin/add.jsx +++ b/src/pages/identity/administration/jit-admin/add.jsx @@ -173,7 +173,10 @@ const Page = () => { }; // Set all template-driven fields + formControl.setValue("useRoles", template.defaultUseRoles ?? true, { shouldDirty: true }); + formControl.setValue("useGroups", template.defaultUseGroups ?? false, { shouldDirty: true }); formControl.setValue("adminRoles", template.defaultRoles || [], { shouldDirty: true }); + formControl.setValue("groupMemberships", template.defaultGroups || [], { shouldDirty: true }); formControl.setValue("expireAction", template.defaultExpireAction || null, { shouldDirty: true, }); diff --git a/src/pages/onboardingv2.js b/src/pages/onboardingv2.js index 59f101d6f8f8..3046798627ac 100644 --- a/src/pages/onboardingv2.js +++ b/src/pages/onboardingv2.js @@ -13,52 +13,64 @@ import { CippDirectTenantDeploy } from "../components/CippWizard/CippDirectTenan import { CippGDAPTenantSetup } from "../components/CippWizard/CippGDAPTenantSetup.jsx"; import { CippGDAPTenantOnboarding } from "../components/CippWizard/CippGDAPTenantOnboarding.jsx"; import { BuildingOfficeIcon, CloudIcon, CpuChipIcon } from "@heroicons/react/24/outline"; +import { useRouter } from "next/router"; const Page = () => { + const router = useRouter(); + const selectedOptionQuery = router.query?.selectedOption; + const deepLinkedOption = Array.isArray(selectedOptionQuery) + ? selectedOptionQuery[0] + : selectedOptionQuery; + const setupOptions = [ + { + description: + "Choose this option if this is your first setup, or if you'd like to redo the previous setup.", + icon: , + label: "First Setup", + value: "FirstSetup", + }, + { + description: "Choose this option if you would like to add a tenant to your environment.", + icon: , + label: "Add a tenant", + value: "AddTenant", + }, + { + description: + "Choose this option if you want to setup which application registration is used to connect to your tenants.", + icon: , + label: "Create a new application registration for me and connect to my tenants", + value: "CreateApp", + }, + { + description: "I would like to refresh my token or replace the account I've used.", + icon: , + label: "Refresh Tokens for existing application registration", + value: "UpdateTokens", + }, + { + description: + "I have an existing application and would like to manually enter my token, or update them. This is only recommended for advanced users.", + icon: , + label: "Manually enter credentials", + value: "Manual", + }, + ]; + + const hasDeepLinkedOption = + typeof deepLinkedOption === "string" && + setupOptions.some((option) => option.value === deepLinkedOption); + const steps = [ { description: "Onboarding", component: CippWizardOptionsList, + hideStepWhen: () => hasDeepLinkedOption, componentProps: { title: "Select your setup method", subtext: `This wizard will guide you through setting up CIPPs access to your client tenants. If this is your first time setting up CIPP you will want to choose the option "Create application for me and connect to my tenants",`, valuesKey: "SyncTool", - options: [ - { - description: - "Choose this option if this is your first setup, or if you'd like to redo the previous setup.", - icon: , - label: "First Setup", - value: "FirstSetup", - }, - { - description: - "Choose this option if you would like to add a tenant to your environment.", - icon: , - label: "Add a tenant", - value: "AddTenant", - }, - { - description: - "Choose this option if you want to setup which application registration is used to connect to your tenants.", - icon: , - label: "Create a new application registration for me and connect to my tenants", - value: "CreateApp", - }, - { - description: "I would like to refresh my token or replace the account I've used.", - icon: , - label: "Refresh Tokens for existing application registration", - value: "UpdateTokens", - }, - { - description: - "I have an existing application and would like to manually enter my token, or update them. This is only recommended for advanced users.", - icon: , - label: "Manually enter credentials", - value: "Manual", - }, - ], + options: setupOptions, }, }, { @@ -137,6 +149,7 @@ const Page = () => { steps={steps} wizardTitle="Setup Wizard" postUrl={"/api/ExecCombinedSetup"} + initialState={hasDeepLinkedOption ? { selectedOption: deepLinkedOption } : undefined} /> ); diff --git a/src/pages/tenant/administration/alert-configuration/alert.jsx b/src/pages/tenant/administration/alert-configuration/alert.jsx index 4f636c58045c..dff059067894 100644 --- a/src/pages/tenant/administration/alert-configuration/alert.jsx +++ b/src/pages/tenant/administration/alert-configuration/alert.jsx @@ -145,7 +145,25 @@ const AlertWizard = () => { alert.RawAlert.PostExecution.split(',').includes(opt.value) ) let tenantFilterForForm - if (alert.RawAlert.TenantGroup) { + if (alert.RawAlert.Tenants) { + // Multi tenant alert - parse stored JSON + try { + const parsedTenants = + typeof alert.RawAlert.Tenants === 'string' + ? JSON.parse(alert.RawAlert.Tenants) + : alert.RawAlert.Tenants + tenantFilterForForm = Array.isArray(parsedTenants) ? parsedTenants : [parsedTenants] + } catch (error) { + console.error('Error parsing Tenants:', error) + tenantFilterForForm = [ + { + value: alert.RawAlert.Tenant, + label: alert.RawAlert.Tenant, + type: 'Tenant', + }, + ] + } + } else if (alert.RawAlert.TenantGroup) { try { const tenantGroupObject = JSON.parse(alert.RawAlert.TenantGroup) tenantFilterForForm = { @@ -156,18 +174,23 @@ const AlertWizard = () => { } } catch (error) { console.error('Error parsing tenant group:', error) - tenantFilterForForm = { + tenantFilterForForm = [ + { + value: alert.RawAlert.Tenant, + label: alert.RawAlert.Tenant, + type: 'Tenant', + }, + ] + } + } else { + // Single tenant + tenantFilterForForm = [ + { value: alert.RawAlert.Tenant, label: alert.RawAlert.Tenant, type: 'Tenant', - } - } - } else { - tenantFilterForForm = { - value: alert.RawAlert.Tenant, - label: alert.RawAlert.Tenant, - type: 'Tenant', - } + }, + ] } let startDateTimeForForm = null if (alert.RawAlert.DesiredStartTime && alert.RawAlert.DesiredStartTime !== '0') { @@ -472,13 +495,16 @@ const AlertWizard = () => { return {} } + const tenants = Array.isArray(values.tenantFilter) ? values.tenantFilter : [values.tenantFilter] + const tenantLabel = tenants.map((t) => t.label || t.value).join(', ') + const postObject = { RowKey: router.query.clone ? undefined : router.query.id ? router.query.id : undefined, tenantFilter: values.tenantFilter, excludedTenants: values.excludedTenants, Name: values.CustomSubject - ? `${values.tenantFilter?.label || values.tenantFilter?.value}: ${values.CustomSubject}` - : `${values.tenantFilter?.label || values.tenantFilter?.value}: ${values.command.label}`, + ? `${tenantLabel}: ${values.CustomSubject}` + : `${tenantLabel}: ${values.command.label}`, Command: { value: `Get-CIPPAlert${values.command.value.name}` }, Parameters: getInputParams(), ScheduledTime: Math.floor(new Date().getTime() / 1000) + 60, @@ -489,7 +515,7 @@ const AlertWizard = () => { CustomSubject: values.CustomSubject, } apiRequest.mutate( - { url: '/api/AddScheduledItem?hidden=true', data: postObject }, + { url: '/api/AddScriptedAlert', data: postObject }, { onSuccess: () => { // Prevent form reload after successful save @@ -884,19 +910,22 @@ const AlertWizard = () => { + value?.length > 0 || + 'At least one tenant or *All Tenants must be selected', }} /> diff --git a/src/pages/tenant/gdap-management/index.js b/src/pages/tenant/gdap-management/index.js index 9aafd4a40531..25e1a1308489 100644 --- a/src/pages/tenant/gdap-management/index.js +++ b/src/pages/tenant/gdap-management/index.js @@ -19,18 +19,16 @@ import CippButtonCard from "../../../components/CippCards/CippButtonCard"; import { WizardSteps } from "../../../components/CippWizard/wizard-steps"; import Link from "next/link"; import { CippHead } from "../../../components/CippComponents/CippHead"; +import { usePermissions } from "../../../hooks/use-permissions"; const Page = () => { const [createDefaults, setCreateDefaults] = useState(false); const [activeStep, setActiveStep] = useState(0); + const { checkRoles } = usePermissions(); + const canViewGdapChecks = checkRoles(["CIPP.AppSettings.Read"]); const relationships = ApiGetCallWithPagination({ - url: "/api/ListGraphRequest", - data: { - Endpoint: "tenantRelationships/delegatedAdminRelationships", - tenantFilter: "", - $top: 300, - }, + url: "/api/ListGDAPRelationships", queryKey: "ListGDAPRelationships", }); @@ -167,46 +165,50 @@ const Page = () => { - - - - - - - - + {canViewGdapChecks && ( + <> + + + + + + + + + + )} ); diff --git a/src/pages/tenant/gdap-management/offboarding.js b/src/pages/tenant/gdap-management/offboarding.js index d9486f775c25..aa6cddc87d0d 100644 --- a/src/pages/tenant/gdap-management/offboarding.js +++ b/src/pages/tenant/gdap-management/offboarding.js @@ -22,60 +22,46 @@ const Page = () => { return vendor.vendorTenantId; }) .join(","); - const vendorGraphFilter = `appOwnerOrganizationId in (${vendorFilter})`; const tenantId = useWatch({ control: formControl.control, name: "tenantFilter", }); const gdapRelationships = ApiGetCall({ - url: "/api/ListGraphRequest", - data: { - Endpoint: "tenantRelationships/delegatedAdminRelationships", - tenantFilter: "", - $top: 300, - }, + url: "/api/ListGDAPRelationships", queryKey: "ListGDAPRelationship", }); const cspContracts = ApiGetCall({ - url: "/api/ListGraphRequest", - data: { - Endpoint: "contracts", - tenantFilter: "", - $top: 300, - }, + url: "/api/ListGDAPContracts", queryKey: "ListContracts", }); const mspApps = ApiGetCall({ - url: "/api/ListGraphRequest", + url: "/api/ListGDAPServicePrincipals", data: { - Endpoint: "servicePrincipals", - TenantFilter: tenantId?.value, - $filter: `appOwnerOrganizationId eq %partnertenantid%`, - $select: "id,displayName,appId,appOwnerOrganizationId", - $count: true, + tenantFilter: tenantId?.value, + ownerType: "partner", }, queryKey: "ListMSPApps-" + tenantId?.value, + waiting: Boolean(tenantId?.value), }); const vendorApps = ApiGetCallWithPagination({ - url: "/api/ListGraphRequest", + url: "/api/ListGDAPServicePrincipals", data: { - Endpoint: "servicePrincipals", - TenantFilter: tenantId?.value, - $filter: vendorGraphFilter, - $select: "id,displayName,appId,appOwnerOrganizationId", - $count: true, + tenantFilter: tenantId?.value, + ownerType: "vendor", + vendorTenantIds: vendorFilter, }, queryKey: "ListVendorApps-" + tenantId?.value, + waiting: Boolean(tenantId?.value), }); return ( <> { label="Select Tenant to Offboard" type="autoComplete" api={{ - url: "/api/ExecExcludeTenant", - data: { - ListAll: true, - }, - queryKey: "ListAllTenants", + url: "/api/ListOffboardTenants", + queryKey: "ListOffboardTenants", labelField: (tenant) => { return `${tenant.displayName} (${tenant.defaultDomainName})`; }, @@ -205,13 +188,11 @@ const Page = () => { label="Vendor Applications to Remove" type="autoComplete" api={{ - url: "/api/ListGraphRequest", + url: "/api/ListGDAPServicePrincipals", data: { - Endpoint: "servicePrincipals", - TenantFilter: tenantId.value, - $filter: vendorGraphFilter, - $select: "id,displayName,appId,appOwnerOrganizationId", - $count: true, + tenantFilter: tenantId.value, + ownerType: "vendor", + vendorTenantIds: vendorFilter, }, dataKey: "Results", queryKey: "ListVendorApps-" + tenantId.value, diff --git a/src/pages/tenant/gdap-management/onboarding/start.js b/src/pages/tenant/gdap-management/onboarding/start.js index c2908d22a467..c5cf9cf5da3a 100644 --- a/src/pages/tenant/gdap-management/onboarding/start.js +++ b/src/pages/tenant/gdap-management/onboarding/start.js @@ -49,11 +49,7 @@ const Page = () => { }); const relationshipList = ApiGetCall({ - url: "/api/ListGraphRequest", - data: { - TenantFilter: "", - Endpoint: "tenantRelationships/delegatedAdminRelationships", - }, + url: "/api/ListGDAPRelationships", queryKey: "GDAPRelationshipOnboarding", }); const onboardingList = ApiGetCallWithPagination({ @@ -317,10 +313,7 @@ const Page = () => { label="Select GDAP Relationship" type="autoComplete" api={{ - url: "/api/ListGraphRequest", - data: { - Endpoint: "tenantRelationships/delegatedAdminRelationships", - }, + url: "/api/ListGDAPRelationships", excludeTenantFilter: true, queryKey: "GDAPRelationships", dataKey: "Results", diff --git a/src/pages/tenant/gdap-management/relationships/index.js b/src/pages/tenant/gdap-management/relationships/index.js index 8c3c3da01bc0..f3a3e4f441a7 100644 --- a/src/pages/tenant/gdap-management/relationships/index.js +++ b/src/pages/tenant/gdap-management/relationships/index.js @@ -4,8 +4,6 @@ import tabOptions from "../tabOptions"; import CippTablePage from "../../../../components/CippComponents/CippTablePage"; import CippGdapActions from "../../../../components/CippComponents/CippGdapActions"; -const pageTitle = "GDAP Relationships"; - const actions = CippGdapActions(); const simpleColumns = [ @@ -47,20 +45,12 @@ const offCanvas = { extendedInfoFields: simpleColumns, }; -const apiUrl = "/api/ListGraphRequest"; -const apiData = { - Endpoint: "tenantRelationships/delegatedAdminRelationships", - tenantFilter: "", - $top: 300, -}; - const Page = () => { return ( { const [relationshipData, setRelationshipData] = useState({}); const relationshipRequest = ApiGetCall({ - url: `/api/ListGraphRequest?Endpoint=tenantRelationships/delegatedAdminRelationships/${id}`, + url: `/api/ListGDAPRelationships?id=${id}`, queryKey: `ListRelationships-${id}`, }); diff --git a/src/pages/tenant/gdap-management/relationships/relationship/mappings.js b/src/pages/tenant/gdap-management/relationships/relationship/mappings.js index 3ec708b70c66..b9669e3f725f 100644 --- a/src/pages/tenant/gdap-management/relationships/relationship/mappings.js +++ b/src/pages/tenant/gdap-management/relationships/relationship/mappings.js @@ -12,7 +12,7 @@ const Page = () => { const { id } = router.query; const relationshipRequest = ApiGetCall({ - url: `/api/ListGraphRequest?Endpoint=tenantRelationships/delegatedAdminRelationships/${id}`, + url: `/api/ListGDAPRelationships?id=${id}`, queryKey: `ListRelationships-${id}`, }); diff --git a/src/pages/tenant/manage/applied-standards.js b/src/pages/tenant/manage/applied-standards.js index 221f7ca2e335..58cc78f19c88 100644 --- a/src/pages/tenant/manage/applied-standards.js +++ b/src/pages/tenant/manage/applied-standards.js @@ -121,6 +121,17 @@ const Page = () => { enabled: !!templateId, // Only run the query if templateId is available }) + // Fetch drift deviation data for drift templates + const isDriftTemplate = selectedTemplate?.type === 'drift' + const driftApi = ApiGetCall({ + url: '/api/listTenantDrift', + data: { + tenantFilter: currentTenant, + }, + queryKey: `TenantDrift-applied-${currentTenant}`, + enabled: isDriftTemplate && !!currentTenant, + }) + useEffect(() => { if (templateId && templateDetails.isSuccess && templateDetails.data) { const selectedTemplate = templateDetails.data.find((template) => template.GUID === templateId) @@ -1165,6 +1176,50 @@ const Page = () => { }) } + // For drift templates, cross-reference with drift deviation data + if (isDriftTemplate && driftApi.isSuccess && driftApi.data) { + const tenantDriftItems = Array.isArray(driftApi.data) + ? driftApi.data.filter((item) => item.tenantFilter === currentTenant) + : [] + + // Build a lookup of standardName -> deviation status + const deviationLookup = {} + tenantDriftItems.forEach((item) => { + if (item.acceptedDeviations) { + item.acceptedDeviations.forEach((dev) => { + if (dev?.standardName) { + deviationLookup[dev.standardName] = 'Accepted' + deviationLookup[`standards.${dev.standardName}`] = 'Accepted' + } + }) + } + if (item.customerSpecificDeviations) { + item.customerSpecificDeviations.forEach((dev) => { + if (dev?.standardName) { + deviationLookup[dev.standardName] = 'CustomerSpecific' + deviationLookup[`standards.${dev.standardName}`] = 'CustomerSpecific' + } + }) + } + }) + + // Update compliance status for accepted deviations + allStandards.forEach((standard) => { + if (standard.complianceStatus === 'Non-Compliant') { + const devStatus = + deviationLookup[standard.standardId] || + deviationLookup[standard.standardId?.replace(/^standards\./, '')] + if (devStatus === 'Accepted') { + standard.complianceStatus = 'Accepted Deviation' + standard.deviationStatus = 'Accepted' + } else if (devStatus === 'CustomerSpecific') { + standard.complianceStatus = 'Customer Specific' + standard.deviationStatus = 'CustomerSpecific' + } + } + }) + } + setComparisonData(allStandards) } else { setComparisonData([]) @@ -1179,6 +1234,10 @@ const Page = () => { comparisonApi.isSuccess, comparisonApi.data, comparisonApi.isError, + isDriftTemplate, + driftApi.isSuccess, + driftApi.data, + currentTenant, ]) const comparisonModeOptions = [{ label: 'Compare Tenant to Standard', value: 'standard' }] @@ -1233,6 +1292,9 @@ const Page = () => { (filter === 'compliant' && standard.complianceStatus === 'Compliant') || (filter === 'nonCompliant' && standard.complianceStatus === 'Non-Compliant') || (filter === 'overridden' && standard.complianceStatus === 'Overridden') || + (filter === 'acceptedDeviation' && + (standard.complianceStatus === 'Accepted Deviation' || + standard.complianceStatus === 'Customer Specific')) || (filter === 'nonCompliantWithLicense' && standard.complianceStatus === 'Non-Compliant' && !hasLicenseMissing) || @@ -1262,6 +1324,12 @@ const Page = () => { comparisonData?.filter((standard) => standard.complianceStatus === 'Compliant').length || 0 const nonCompliantCount = comparisonData?.filter((standard) => standard.complianceStatus === 'Non-Compliant').length || 0 + const acceptedDeviationCount = + comparisonData?.filter( + (standard) => + standard.complianceStatus === 'Accepted Deviation' || + standard.complianceStatus === 'Customer Specific' + ).length || 0 const reportingDisabledCount = comparisonData?.filter((standard) => standard.complianceStatus === 'Reporting Disabled') .length || 0 @@ -1297,7 +1365,9 @@ const Page = () => { const compliancePercentage = allCount > 0 ? Math.round( - (compliantCount / (allCount - reportingDisabledCount - overriddenCount || 1)) * 100 + ((compliantCount + acceptedDeviationCount) / + (allCount - reportingDisabledCount - overriddenCount || 1)) * + 100 ) : 0 @@ -1371,7 +1441,7 @@ const Page = () => { backUrl="/tenant/standards" actions={actions} actionsData={{}} - isFetching={comparisonApi.isFetching || templateDetails.isFetching} + isFetching={comparisonApi.isFetching || templateDetails.isFetching || driftApi.isFetching} > @@ -1606,6 +1676,17 @@ const Page = () => { combinedScore >= 80 ? 'success' : combinedScore >= 60 ? 'warning' : 'error' } /> + + {isDriftTemplate ? : } + + } + label={isDriftTemplate ? 'Drift Standard' : 'Classic Standard'} + size="small" + color={isDriftTemplate ? 'info' : 'default'} + variant="outlined" + /> )} { > Overridden ({overriddenCount}) + {isDriftTemplate && acceptedDeviationCount > 0 && ( + { + setFilter('acceptedDeviation') + setFilterMenuAnchor(null) + }} + > + Accepted Deviations ({acceptedDeviationCount}) + + )} { @@ -1780,7 +1872,10 @@ const Page = () => { ? 'warning.main' : standard.complianceStatus === 'Reporting Disabled' ? 'grey.500' - : 'error.main', + : standard.complianceStatus === 'Accepted Deviation' || + standard.complianceStatus === 'Customer Specific' + ? 'info.main' + : 'error.main', }} > {standard.complianceStatus === 'Compliant' ? ( @@ -1789,6 +1884,9 @@ const Page = () => { ) : standard.complianceStatus === 'Reporting Disabled' ? ( + ) : standard.complianceStatus === 'Accepted Deviation' || + standard.complianceStatus === 'Customer Specific' ? ( + ) : ( )} @@ -2032,7 +2130,11 @@ const Page = () => { ? 'warning.main' : standard.complianceStatus === 'Reporting Disabled' ? 'grey.500' - : 'error.main', + : standard.complianceStatus === + 'Accepted Deviation' || + standard.complianceStatus === 'Customer Specific' + ? 'info.main' + : 'error.main', borderRadius: '50%', width: 8, height: 8, @@ -2500,7 +2602,10 @@ const Page = () => { ? 'warning.main' : standard.complianceStatus === 'Reporting Disabled' ? 'text.secondary' - : 'error.main', + : standard.complianceStatus === 'Accepted Deviation' || + standard.complianceStatus === 'Customer Specific' + ? 'info.main' + : 'error.main', fontWeight: standard.complianceStatus === 'Non-Compliant' ? 'medium' diff --git a/src/pages/tenant/manage/drift.js b/src/pages/tenant/manage/drift.js index 015ad73c6000..8c383590ed04 100644 --- a/src/pages/tenant/manage/drift.js +++ b/src/pages/tenant/manage/drift.js @@ -107,6 +107,49 @@ const ManageDriftPage = () => { enabled: !!templateId && !!tenantFilter, }) + // API call for persistent drift remediation tasks + const persistentDriftTasksApi = ApiGetCall({ + url: '/api/ListScheduledItems', + data: { + tenantFilter: tenantFilter, + SearchTitle: 'Persistent Drift Remediation:*', + }, + queryKey: `PersistentDriftTasks-${tenantFilter}`, + waiting: !!tenantFilter, + }) + + const persistentTaskNameSet = new Set( + (persistentDriftTasksApi.data || []) + .map((task) => (task?.Name ? String(task.Name).toLowerCase() : null)) + .filter(Boolean) + ) + + const getDriftTaskSettingName = (standardName) => { + if (!standardName) return '' + + const normalizedName = String(standardName) + const withoutPrefix = normalizedName.replace(/^standards\./, '') + + if (withoutPrefix.startsWith('IntuneTemplate.')) { + return 'IntuneTemplate' + } + + if (withoutPrefix.startsWith('ConditionalAccessTemplate.')) { + return 'ConditionalAccessTemplate' + } + + return withoutPrefix + } + + const hasPersistentDenyTask = (standardName) => { + const settingName = getDriftTaskSettingName(standardName) + if (!settingName || !tenantFilter) return false + + const expectedTaskName = + `Persistent Drift Remediation: ${settingName} - ${tenantFilter}`.toLowerCase() + return persistentTaskNameSet.has(expectedTaskName) + } + // Process drift data for chart - filter by current tenant and aggregate const rawDriftData = driftApi.data || [] const tenantDriftData = Array.isArray(rawDriftData) @@ -512,6 +555,7 @@ const ManageDriftPage = () => { : isLicenseSkipped ? 'Skipped - No License Available' : getDeviationStatusText(actualStatus) + const isPersistentDenyEnabled = hasPersistentDenyTask(deviation.standardName) // For skipped items, show different expected/received values let displayExpectedValue = deviation.ExpectedValue || deviation.expectedValue @@ -536,15 +580,29 @@ const ManageDriftPage = () => { text: prettyName, subtext: description, statusColor: isLicenseSkipped ? 'text.secondary' : getDeviationColor(actualStatus), - statusText: actualStatusText, + statusText: isPersistentDenyEnabled + ? `${actualStatusText} | Persistent deny (12h)` + : actualStatusText, standardName: deviation.standardName, // Store the original standardName for action handlers receivedValue: deviation.receivedValue, // Store the original receivedValue for action handlers expectedValue: deviation.expectedValue, // Store the original expectedValue for action handlers originalDeviation: deviation, // Store the complete original deviation object for reference isLicenseSkipped: isLicenseSkipped, // Flag for filtering and disabling actions isActuallyCompliant: isActuallyCompliant, // Flag to move to compliant section + isPersistentDenyEnabled: isPersistentDenyEnabled, children: ( + {isPersistentDenyEnabled && ( + + + + )} + {description && description !== 'No description available' && ( {description} @@ -1134,6 +1192,17 @@ const ManageDriftPage = () => { const handleDeviationAction = (action, deviation) => { if (!deviation) return + const resolvedReceivedValue = + deviation.receivedValue ?? + deviation.CurrentValue ?? + deviation.currentValue ?? + deviation.originalDeviation?.receivedValue ?? + deviation.originalDeviation?.CurrentValue ?? + deviation.originalDeviation?.currentValue ?? + deviation.expectedValue ?? + deviation.ExpectedValue ?? + null + let status let actionText switch (action) { @@ -1168,7 +1237,7 @@ const ManageDriftPage = () => { { standardName: deviation.standardName, // Use the standardName from the original deviation data status: status, - receivedValue: deviation.receivedValue, + receivedValue: resolvedReceivedValue, }, ], tenantFilter: tenantFilter, @@ -1407,6 +1476,25 @@ const ManageDriftPage = () => { ), })) + // Add action buttons to compliant/aligned items so previously denied and now compliant entries + // can be denied again or denied with remediation persistence. + const alignedItemsWithActions = allAlignedItems.map((item) => ({ + ...item, + cardLabelBoxActions: ( + + ), + })) + // Calculate compliance metrics for badges // Accepted and Customer Specific deviations count as compliant since they are user-approved // Denied deviations are included in total but not in compliant count (they haven't been fixed yet) @@ -1485,7 +1573,7 @@ const ManageDriftPage = () => { const filteredAcceptedItems = applyFilters(acceptedDeviationItemsWithActions) const filteredCustomerSpecificItems = applyFilters(customerSpecificDeviationItemsWithActions) const filteredDeniedItems = applyFilters(deniedDeviationItemsWithActions) - const filteredAlignedItems = applyFilters(allAlignedItems) + const filteredAlignedItems = applyFilters(alignedItemsWithActions) const filteredLicenseSkippedItems = applyFilters(licenseSkippedItems) // Helper function to render items grouped by category when category sort is active @@ -1569,7 +1657,12 @@ const ManageDriftPage = () => { subtitle={subtitle} actions={actions} actionsData={{}} - isFetching={driftApi.isFetching || standardsApi.isFetching || comparisonApi.isFetching} + isFetching={ + driftApi.isFetching || + standardsApi.isFetching || + comparisonApi.isFetching || + persistentDriftTasksApi.isFetching + } > @@ -1918,12 +2011,7 @@ const ManageDriftPage = () => { Compliant Standards - + {renderItemsByCategory(filteredAlignedItems)} )} @@ -1989,7 +2077,7 @@ const ManageDriftPage = () => { }, }} row={actionData.data} - relatedQueryKeys={[`TenantDrift-${tenantFilter}`]} + relatedQueryKeys={[`TenantDrift-${tenantFilter}`, `PersistentDriftTasks-${tenantFilter}`]} /> )} @@ -2148,6 +2236,15 @@ const ManageDriftPage = () => { open={Boolean(anchorEl[`denied-${item.id}`])} onClose={() => handleMenuClose(`denied-${item.id}`)} > + { + handleDeviationAction('deny', item) + handleMenuClose(`denied-${item.id}`) + }} + > + + Rerun standard to align with template + { handleDeviationAction('deny-remediate', item) @@ -2178,6 +2275,34 @@ const ManageDriftPage = () => { ))} + {alignedItemsWithActions.map((item) => ( + handleMenuClose(`aligned-${item.id}`)} + > + { + handleDeviationAction('deny', item) + handleMenuClose(`aligned-${item.id}`) + }} + > + + Rerun standard to align with template + + { + handleDeviationAction('deny-remediate', item) + handleMenuClose(`aligned-${item.id}`) + }} + > + + Deny - Remediate to align with template + + + ))} + {/* Hidden ExecutiveReportButton that gets triggered programmatically */} { - return ; + const currentTenant = useSettings().currentTenant; + const syncDialog = useDialog(); + const [syncQueueId, setSyncQueueId] = useState(null); + + const isAllTenants = currentTenant === "AllTenants"; + const [useReportDB, setUseReportDB] = useState(true); + + useEffect(() => { + setUseReportDB(true); + }, [currentTenant]); + + const simpleColumns = isAllTenants + ? ["Tenant", "Name", "ApplicationID", "ObjectID", "Scope", "StartTime", "CacheTimestamp"] + : ["Name", "ApplicationID", "ObjectID", "Scope", "StartTime", "CacheTimestamp"]; + + return ( + <> + + {useReportDB && ( + <> + + + + )} + + + : } + label={useReportDB ? "Cached" : "Live"} + color="primary" + size="small" + onClick={isAllTenants ? undefined : () => setUseReportDB((prev) => !prev)} + clickable={!isAllTenants} + disabled={isAllTenants} + variant="outlined" + /> + + + + } + /> + { + if (response?.Metadata?.QueueId) { + setSyncQueueId(response.Metadata.QueueId); + } + }, + }} + /> + + ); }; -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page}; export default Page; diff --git a/src/pages/tenant/reports/graph-office-reports/index.js b/src/pages/tenant/reports/graph-office-reports/index.js index b97505d12ed4..39bb153c06b6 100644 --- a/src/pages/tenant/reports/graph-office-reports/index.js +++ b/src/pages/tenant/reports/graph-office-reports/index.js @@ -64,7 +64,7 @@ const Page = () => { waiting: !!currentTenant, }) - const reportOptions = (reportListApi.data ?? []).map((r) => ({ + const reportOptions = (Array.isArray(reportListApi.data) ? reportListApi.data : []).map((r) => ({ label: prettifyReportName(r.name), value: r.name, type: r.type ?? null, @@ -156,7 +156,7 @@ const Page = () => { ) : ( { const pageTitle = 'Standard & Drift Alignment' + const [granular, setGranular] = useState(false) - const filterList = [ + const summaryFilterList = [ { filterName: 'Drift Templates', value: [{ id: 'standardType', value: 'drift' }], @@ -21,7 +25,35 @@ const Page = () => { }, ] - const actions = [ + const granularFilterList = [ + { + filterName: 'Non-Compliant', + value: [{ id: 'complianceStatus', value: 'Non-Compliant' }], + type: 'column', + }, + { + filterName: 'Compliant', + value: [{ id: 'complianceStatus', value: 'Compliant' }], + type: 'column', + }, + { + filterName: 'Accepted Deviation', + value: [{ id: 'complianceStatus', value: 'Accepted Deviation' }], + type: 'column', + }, + { + filterName: 'Customer Specific', + value: [{ id: 'complianceStatus', value: 'Customer Specific' }], + type: 'column', + }, + { + filterName: 'License Missing', + value: [{ id: 'complianceStatus', value: 'License Missing' }], + type: 'column', + }, + ] + + const summaryActions = [ { label: 'View Tenant Report', link: '/tenant/manage/applied-standards/?tenantFilter=[tenantFilter]&templateId=[standardId]', @@ -29,6 +61,13 @@ const Page = () => { color: 'info', target: '_self', }, + { + label: 'Edit Template', + link: '/tenant/standards/templates/template?id=[standardId]&type=[standardType]', + icon: , + color: 'success', + target: '_self', + }, { label: 'Manage Drift', link: '/tenant/manage/drift?templateId=[standardId]&tenantFilter=[tenantFilter]', @@ -53,22 +92,401 @@ const Page = () => { }, ] + const granularActions = [ + { + label: 'View Tenant Report', + link: '/tenant/manage/applied-standards/?tenantFilter=[tenantFilter]&templateId=[templateId]', + icon: , + color: 'info', + target: '_self', + }, + { + label: 'Edit Template', + link: '/tenant/standards/templates/template?id=[templateId]&type=[templateType]', + icon: , + color: 'success', + target: '_self', + }, + { + label: 'Manage Drift', + link: '/tenant/manage/drift?templateId=[templateId]&tenantFilter=[tenantFilter]', + icon: , + color: 'info', + target: '_self', + condition: (row) => row.templateType === 'drift', + }, + ] + + const parseValue = (value) => { + if (value === null || value === undefined || value === '') return null + if (typeof value === 'string') { + try { + return JSON.parse(value) + } catch { + return value + } + } + return value + } + + const normalizeObj = (val) => { + if (Array.isArray(val)) return val.map(normalizeObj) + if (val !== null && typeof val === 'object') { + return Object.fromEntries( + Object.keys(val) + .sort() + .map((k) => [k, normalizeObj(val[k])]) + ) + } + return val + } + + const compareValues = (expected, current) => { + if (!expected || !current) return null + try { + const expectedObj = normalizeObj( + typeof expected === 'object' ? expected : JSON.parse(expected) + ) + const currentObj = normalizeObj(typeof current === 'object' ? current : JSON.parse(current)) + if (JSON.stringify(expectedObj) === JSON.stringify(currentObj)) return null + const differences = {} + const allKeys = new Set([...Object.keys(expectedObj), ...Object.keys(currentObj)]) + allKeys.forEach((key) => { + const e = normalizeObj(expectedObj[key]) + const c = normalizeObj(currentObj[key]) + if (JSON.stringify(e) !== JSON.stringify(c)) { + differences[key] = { expected: expectedObj[key], current: currentObj[key] } + } + }) + return Object.keys(differences).length > 0 ? differences : null + } catch { + return null + } + } + + const granularOffCanvas = { + size: 'md', + title: 'Standard Details', + contentPadding: 0, + children: (row) => { + const expectedParsed = parseValue(row.expectedValue) + const currentParsed = parseValue(row.currentValue) + const diffs = compareValues(expectedParsed, currentParsed) + const baseName = row.standardId?.split('.').slice(0, -1).join('.') + const prettyName = + standardsData.find((s) => s.name === row.standardId)?.label ?? + standardsData.find((s) => s.name === baseName)?.label ?? + row.standardName + + const complianceColors = { + compliant: 'success', + 'non-compliant': 'error', + 'accepted deviation': 'info', + 'customer specific': 'info', + 'license missing': 'warning', + 'reporting disabled': 'default', + } + const statusColor = + complianceColors[String(row.complianceStatus ?? '').toLowerCase()] ?? 'default' + + const properties = [ + { label: 'Standard', value: prettyName }, + { label: 'Status', value: row.complianceStatus, color: statusColor }, + { label: 'Template', value: row.templateName }, + { + label: 'Type', + value: row.standardType === 'drift' ? 'Drift Standard' : 'Classic Standard', + }, + { + label: 'Last Applied', + value: row.latestDataCollection + ? new Date(row.latestDataCollection).toLocaleString() + : 'N/A', + }, + ] + + return ( + + {/* Property list */} + } + sx={{ borderBottom: '1px solid', borderColor: 'divider' }} + > + {properties.map(({ label, value, color }) => ( + + + {label} + + {color ? ( + + ) : ( + + {value ?? 'N/A'} + + )} + + ))} + + + {/* Diff / value content */} + {(expectedParsed || currentParsed) && ( + + {diffs ? ( + <> + + Property Differences + + {Object.entries(diffs).map(([key, { expected, current }]) => ( + + + {key} + + + + + Expected + + + + {JSON.stringify(expected, null, 2)} + + + + + + Current + + + + {JSON.stringify(current, null, 2)} + + + + + + ))} + + ) : ( + <> + {expectedParsed !== null && ( + + + Expected + + + + {typeof expectedParsed === 'object' + ? JSON.stringify(expectedParsed, null, 2) + : String(expectedParsed)} + + + + )} + {currentParsed !== null && ( + + + Current + + + + {typeof currentParsed === 'object' + ? JSON.stringify(currentParsed, null, 2) + : String(currentParsed)} + + + + )} + + )} + + )} + + ) + }, + } + + const modeToggle = ( + + + + ) : ( + + ) + } + label={granular ? 'Per Standard' : 'Summary'} + onClick={() => setGranular((v) => !v)} + color="primary" + variant="filled" + size="small" + clickable + /> + + + ) + return ( ) } diff --git a/src/pages/tenant/tools/graph-explorer/index.js b/src/pages/tenant/tools/graph-explorer/index.js index 2cbbd17a3ba1..bf4fde876376 100644 --- a/src/pages/tenant/tools/graph-explorer/index.js +++ b/src/pages/tenant/tools/graph-explorer/index.js @@ -18,9 +18,9 @@ const Page = () => { const apiData = ApiGetCallWithPagination({ url: apiFilter.endpoint ? "/api/ListGraphRequest" : "/api/ListEmptyResults", - data: apiFilter, + data: { tenantFilter, ...apiFilter }, queryKey: queryKey, - waiting: !!apiFilter.endpoint, + waiting: !!apiFilter.endpoint && viewMode === "json", }); const jsonData = apiData?.data?.pages?.[0]?.Results || apiData?.data || {}; diff --git a/src/pages/tools/community-repos/index.js b/src/pages/tools/community-repos/index.js index 4213f74127d9..8b09c824d1e1 100644 --- a/src/pages/tools/community-repos/index.js +++ b/src/pages/tools/community-repos/index.js @@ -82,6 +82,7 @@ const Page = () => { icon: , multiPost: false, queryKey: "CommunityRepos", + condition: (row) => row.BuiltIn !== true, }, { label: "Set Upload Branch", diff --git a/src/pages/tools/custom-tests/add.jsx b/src/pages/tools/custom-tests/add.jsx index fb4387bbbdb8..6951fe57c507 100644 --- a/src/pages/tools/custom-tests/add.jsx +++ b/src/pages/tools/custom-tests/add.jsx @@ -406,7 +406,7 @@ All UPNs: {{join(Result[*].UserPrincipalName, ", ")}}`, placeholder: `# Example: Find disabled users with licenses param($TenantFilter, $DaysThreshold = 30) -$users = New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Users' +$users = Get-CIPPTestData -TenantFilter $TenantFilter -Type 'Users' $results = $users | Where-Object { $_.assignedLicenses.Count -gt 0 -and $_.accountEnabled -eq $false @@ -655,7 +655,7 @@ return $results`, Data Access - Read-only via New-CIPPDbRequest and Get-CIPPDbItem{' '} + Read-only via Get-CIPPTestData and Get-CIPPDbItem{' '} with a -Type parameter.