diff --git a/.github/workflows/CodeQL_Analyser.yml b/.github/workflows/CodeQL_Analyser.yml index 11aa414aaf79..027a7d589437 100644 --- a/.github/workflows/CodeQL_Analyser.yml +++ b/.github/workflows/CodeQL_Analyser.yml @@ -1,6 +1,10 @@ ---- +# OPTIONAL UPGRADE to the existing CodeQL_Analyser.yml (already deployed in CIPP). +# Changes vs. current: adds push trigger on main/dev, security-extended query suite, +# and javascript-typescript language alias. name: "CodeQL" on: + push: + branches: [main, dev] pull_request: branches: [master, main, dev, react] schedule: @@ -17,7 +21,7 @@ jobs: strategy: fail-fast: false matrix: - language: ["javascript"] + language: ["javascript-typescript"] steps: - name: Checkout Repository uses: actions/checkout@v6 @@ -25,6 +29,7 @@ jobs: uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} + queries: security-extended - name: Autobuild uses: github/codeql-action/autobuild@v4 - name: Perform CodeQL Analysis diff --git a/.github/workflows/cipp_dev_build.yml b/.github/workflows/cipp_dev_build.yml index c72249f2eff1..1ea3b9ebf9e1 100644 --- a/.github/workflows/cipp_dev_build.yml +++ b/.github/workflows/cipp_dev_build.yml @@ -54,7 +54,7 @@ jobs: # Upload to Azure Blob Storage - name: Azure Blob Upload - uses: LanceMcCarthy/Action-AzureBlobUpload@v3.12.0 + uses: LanceMcCarthy/Action-AzureBlobUpload@v3.15.0 with: connection_string: ${{ secrets.AZURE_CONNECTION_STRING }} container_name: cipp diff --git a/.github/workflows/cipp_frontend_build.yml b/.github/workflows/cipp_frontend_build.yml index 7e13dd02fa3e..e17dffae254e 100644 --- a/.github/workflows/cipp_frontend_build.yml +++ b/.github/workflows/cipp_frontend_build.yml @@ -54,7 +54,7 @@ jobs: # Upload to Azure Blob Storage - name: Azure Blob Upload - uses: LanceMcCarthy/Action-AzureBlobUpload@v3.12.0 + uses: LanceMcCarthy/Action-AzureBlobUpload@v3.15.0 with: connection_string: ${{ secrets.AZURE_CONNECTION_STRING }} container_name: cipp diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 000000000000..c508dd90d6e9 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,28 @@ +# SCA: Fails PRs that introduce dependencies with known vulnerabilities +# ISO 27001:2022 A.8.28/8.29 evidence — Software Composition Analysis at merge time +name: Dependency Review +on: + pull_request: + branches: [main, dev] + +permissions: + contents: read + pull-requests: write + +jobs: + dependency-review: + if: github.repository_owner == 'KelvinTegelaar' + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Dependency review + uses: actions/dependency-review-action@v4 + with: + # Block merge on known vulnerabilities of moderate severity or higher + fail-on-severity: moderate + # Surface results as a PR comment for contributor visibility + comment-summary-in-pr: on-failure + # Optional: block copyleft-incompatible licenses (adjust for AGPL-3.0 project policy) + # deny-licenses: GPL-1.0-only diff --git a/.github/workflows/zap-scan.yml b/.github/workflows/zap-scan.yml new file mode 100644 index 000000000000..98d26fcad305 --- /dev/null +++ b/.github/workflows/zap-scan.yml @@ -0,0 +1,55 @@ +# DAST: OWASP ZAP scans against the CIPP staging deployment +# ISO 27001:2022 A.8.29 evidence — Dynamic Application Security Testing +# +# Prerequisites: +# - Repo variable STAGING_URL pointing at a staging deployment (test-tenant data only, never production) +# - Optional: .zap/rules.tsv to suppress documented false positives (annotate each with justification) +name: DAST - OWASP ZAP Scan +on: + schedule: + - cron: "0 4 * * 1" # Weekly, Monday 04:00 UTC + workflow_dispatch: # Run on demand before each versioned release + inputs: + full_scan: + description: "Run full (active) scan instead of baseline" + type: boolean + default: false + +permissions: + contents: read + issues: write # ZAP action files findings as GitHub issues + +jobs: + zap_baseline: + if: github.repository_owner == 'KelvinTegelaar' && (github.event_name == 'schedule' || !inputs.full_scan) + name: ZAP Baseline Scan (passive) + runs-on: ubuntu-latest + steps: + - name: Checkout (for .zap rules file) + uses: actions/checkout@v6 + + - name: ZAP baseline scan + uses: zaproxy/action-baseline@v0.14.0 + with: + target: ${{ vars.STAGING_URL }} + rules_file_name: ".zap/rules.tsv" + allow_issue_writing: true + issue_title: "ZAP baseline scan findings" + artifact_name: zap-baseline-report + + zap_full: + if: github.repository_owner == 'KelvinTegelaar' && github.event_name == 'workflow_dispatch' && inputs.full_scan + name: ZAP Full Scan (active, pre-release) + runs-on: ubuntu-latest + steps: + - name: Checkout (for .zap rules file) + uses: actions/checkout@v6 + + - name: ZAP full scan + uses: zaproxy/action-full-scan@v0.12.0 + with: + target: ${{ vars.STAGING_URL }} + rules_file_name: ".zap/rules.tsv" + allow_issue_writing: true + issue_title: "ZAP full scan findings (pre-release)" + artifact_name: zap-full-report diff --git a/package.json b/package.json index 94b04e986410..2f20a1dfff69 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cipp", - "version": "10.5.2", + "version": "10.5.5", "author": "CIPP Contributors", "homepage": "https://cipp.app/", "bugs": { @@ -42,20 +42,20 @@ "@react-pdf/renderer": "^4.5.1", "@reduxjs/toolkit": "^2.12.0", "@tanstack/query-sync-storage-persister": "^5.90.25", - "@tanstack/react-query": "^5.100.10", + "@tanstack/react-query": "^5.101.2", "@tanstack/react-query-devtools": "^5.100.10", "@tanstack/react-query-persist-client": "^5.96.2", "@tanstack/react-table": "^8.19.2", "@tiptap/core": "^3.22.3", "@tiptap/extension-heading": "^3.22.3", "@tiptap/extension-table": "^3.20.5", - "@tiptap/pm": "^3.22.3", + "@tiptap/pm": "^3.27.1", "@tiptap/react": "^3.20.5", "@tiptap/starter-kit": "^3.20.5", "@vvo/tzdb": "^6.198.0", "apexcharts": "5.14.0", "axios": "1.16.1", - "date-fns": "4.1.0", + "date-fns": "4.4.0", "diff": "^8.0.3", "dompurify": "^3.4.9", "driver.js": "^1.4.0", @@ -65,7 +65,7 @@ "gray-matter": "4.0.3", "javascript-time-ago": "^2.6.2", "jspdf": "^4.2.0", - "jspdf-autotable": "^5.0.7", + "jspdf-autotable": "^5.0.8", "leaflet": "^1.9.4", "leaflet.markercluster": "^1.5.3", "lodash": "^4.18.1", @@ -73,17 +73,17 @@ "material-react-table": "^3.0.1", "monaco-editor": "^0.55.1", "mui-tiptap": "^1.31.0", - "next": "^16.2.2", + "next": "^16.2.9", "nprogress": "0.2.0", "numeral": "2.0.6", "prop-types": "15.8.1", "punycode": "^2.3.1", "react": "19.2.6", - "react-apexcharts": "2.1.0", + "react-apexcharts": "2.1.1", "react-beautiful-dnd": "13.1.1", "react-dom": "19.2.6", "react-dropzone": "15.0.0", - "react-error-boundary": "^6.1.1", + "react-error-boundary": "^6.1.2", "react-hook-form": "^7.76.1", "react-hot-toast": "2.6.0", "react-html-parser": "^2.0.2", @@ -93,7 +93,7 @@ "react-media-hook": "^0.5.0", "react-papaparse": "^4.4.0", "react-quill": "^2.0.0", - "react-redux": "9.2.0", + "react-redux": "9.3.0", "react-syntax-highlighter": "^16.1.0", "react-time-ago": "^7.3.3", "react-virtuoso": "^4.18.7", @@ -112,7 +112,7 @@ "devDependencies": { "@svgr/webpack": "8.1.0", "eslint": "^9.39.4", - "eslint-config-next": "^16.2.3", + "eslint-config-next": "^16.2.10", "eslint-config-prettier": "^10.1.8", "prettier": "^3.8.1" } diff --git a/public/version.json b/public/version.json index 326768d361ba..6c7341794d6a 100644 --- a/public/version.json +++ b/public/version.json @@ -1,3 +1,3 @@ { - "version": "10.5.2" + "version": "10.5.5" } diff --git a/src/components/CippCards/CippChartCard.jsx b/src/components/CippCards/CippChartCard.jsx index 577a3f2bbaf1..7afbe0ac32ea 100644 --- a/src/components/CippCards/CippChartCard.jsx +++ b/src/components/CippCards/CippChartCard.jsx @@ -110,10 +110,11 @@ export const CippChartCard = ({ })) ); } - }, [chartType, chartSeries.length, labels]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chartType, chartSeries.join(","), labels.join(",")]); return ( - { - const { exchangeData, isLoading = false, isFetching = false, handleRefresh, ...other } = props; + const { exchangeData, isLoading = false, isFetching = false, handleRefresh, ...other } = props // Define the protocols array const protocols = [ - { name: "EWS", enabled: exchangeData?.EWSEnabled }, - { name: "MAPI", enabled: exchangeData?.MailboxMAPIEnabled }, - { name: "OWA", enabled: exchangeData?.MailboxOWAEnabled }, - { name: "IMAP", enabled: exchangeData?.MailboxImapEnabled }, - { name: "POP", enabled: exchangeData?.MailboxPopEnabled }, - { name: "ActiveSync", enabled: exchangeData?.MailboxActiveSyncEnabled }, - ]; + { name: 'EWS', enabled: exchangeData?.EWSEnabled }, + { name: 'MAPI', enabled: exchangeData?.MailboxMAPIEnabled }, + { name: 'OWA', enabled: exchangeData?.MailboxOWAEnabled }, + { name: 'IMAP', enabled: exchangeData?.MailboxImapEnabled }, + { name: 'POP', enabled: exchangeData?.MailboxPopEnabled }, + { name: 'ActiveSync', enabled: exchangeData?.MailboxActiveSyncEnabled }, + { + // SMTP client auth is inverted: true = disabled (secure), false = enabled (risk), + // null = unknown. Label spells out the state so a green chip isn't misread as "on". + name: + exchangeData?.SmtpClientAuthenticationDisabled == null + ? 'SMTP Unknown' + : exchangeData?.SmtpClientAuthenticationDisabled === false + ? 'SMTP Enabled' + : 'SMTP Disabled', + enabled: exchangeData?.SmtpClientAuthenticationDisabled === false, + unknown: exchangeData?.SmtpClientAuthenticationDisabled == null, + riskWhenEnabled: true, + }, + ] // Define mailbox hold types array const holds = [ - { name: "Compliance Tag Hold", enabled: exchangeData?.ComplianceTagHold }, - { name: "Retention Hold", enabled: exchangeData?.RetentionHold }, - { name: "Litigation Hold", enabled: exchangeData?.LitigationHold }, - { name: "In-Place Hold", enabled: exchangeData?.InPlaceHold }, - { name: "eDiscovery Hold", enabled: exchangeData?.EDiscoveryHold }, - { name: "Purview Retention Hold", enabled: exchangeData?.PurviewRetentionHold }, - { name: "Excluded from Org-Wide Hold", enabled: exchangeData?.ExcludedFromOrgWideHold }, - ]; + { name: 'Compliance Tag Hold', enabled: exchangeData?.ComplianceTagHold }, + { name: 'Retention Hold', enabled: exchangeData?.RetentionHold }, + { name: 'Litigation Hold', enabled: exchangeData?.LitigationHold }, + { name: 'In-Place Hold', enabled: exchangeData?.InPlaceHold }, + { name: 'eDiscovery Hold', enabled: exchangeData?.EDiscoveryHold }, + { name: 'Purview Retention Hold', enabled: exchangeData?.PurviewRetentionHold }, + { name: 'Excluded from Org-Wide Hold', enabled: exchangeData?.ExcludedFromOrgWideHold }, + ] return ( @@ -47,7 +60,7 @@ export const CippExchangeInfoCard = (props) => { title={ Exchange Information {isFetching ? ( @@ -79,7 +92,7 @@ export const CippExchangeInfoCard = (props) => { Mailbox Type: - {exchangeData?.RecipientTypeDetails || "N/A"} + {exchangeData?.RecipientTypeDetails || 'N/A'} @@ -89,7 +102,7 @@ export const CippExchangeInfoCard = (props) => { {getCippFormatting( exchangeData?.HiddenFromAddressLists, - "HiddenFromAddressLists", + 'HiddenFromAddressLists' )} @@ -98,7 +111,7 @@ export const CippExchangeInfoCard = (props) => { Blocked For Spam: - {getCippFormatting(exchangeData?.BlockedForSpam, "BlockedForSpam")} + {getCippFormatting(exchangeData?.BlockedForSpam, 'BlockedForSpam')} @@ -106,7 +119,7 @@ export const CippExchangeInfoCard = (props) => { Retention Policy: - {getCippFormatting(exchangeData?.RetentionPolicy, "RetentionPolicy")} + {getCippFormatting(exchangeData?.RetentionPolicy, 'RetentionPolicy')} @@ -121,21 +134,21 @@ export const CippExchangeInfoCard = (props) => { ) : exchangeData?.TotalItemSize != null ? ( ) : ( - "N/A" + 'N/A' ) } /> @@ -146,47 +159,47 @@ export const CippExchangeInfoCard = (props) => { ) : ( (() => { - const forwardingAddress = exchangeData?.ForwardingAddress; - const forwardAndDeliver = exchangeData?.ForwardAndDeliver; + const forwardingAddress = exchangeData?.ForwardingAddress + const forwardAndDeliver = exchangeData?.ForwardAndDeliver // Determine forwarding type and clean address - let forwardingType = "None"; - let cleanAddress = ""; + let forwardingType = 'None' + let cleanAddress = '' if (forwardingAddress) { // Handle array of forwarding addresses if (Array.isArray(forwardingAddress)) { cleanAddress = forwardingAddress .map((addr) => - typeof addr === "string" ? addr.replace(/^smtp:/i, "") : String(addr), + typeof addr === 'string' ? addr.replace(/^smtp:/i, '') : String(addr) ) - .join(", "); + .join(', ') // Check if any address has smtp: prefix (external) or contains @ (external email) forwardingType = forwardingAddress.some( (addr) => - (typeof addr === "string" && addr.toLowerCase().startsWith("smtp:")) || - (typeof addr === "string" && addr.includes("@")), + (typeof addr === 'string' && addr.toLowerCase().startsWith('smtp:')) || + (typeof addr === 'string' && addr.includes('@')) ) - ? "External" - : "Internal"; + ? 'External' + : 'Internal' } // Handle single string address - else if (typeof forwardingAddress === "string") { - if (forwardingAddress.startsWith("smtp:")) { - forwardingType = "External"; - cleanAddress = forwardingAddress.replace(/^smtp:/i, ""); - } else if (forwardingAddress.includes("@")) { - forwardingType = "External"; - cleanAddress = forwardingAddress; + else if (typeof forwardingAddress === 'string') { + if (forwardingAddress.startsWith('smtp:')) { + forwardingType = 'External' + cleanAddress = forwardingAddress.replace(/^smtp:/i, '') + } else if (forwardingAddress.includes('@')) { + forwardingType = 'External' + cleanAddress = forwardingAddress } else { - forwardingType = "Internal"; - cleanAddress = forwardingAddress; + forwardingType = 'Internal' + cleanAddress = forwardingAddress } } // Fallback for other types else { - forwardingType = "Internal"; - cleanAddress = String(forwardingAddress); + forwardingType = 'Internal' + cleanAddress = String(forwardingAddress) } } @@ -197,19 +210,19 @@ export const CippExchangeInfoCard = (props) => { Forwarding Status: - {forwardingType === "None" - ? getCippFormatting(false, "ForwardingStatus") + {forwardingType === 'None' + ? getCippFormatting(false, 'ForwardingStatus') : `${forwardingType} Forwarding`} - {forwardingType !== "None" && ( + {forwardingType !== 'None' && ( <> Keep Copy in Mailbox: - {getCippFormatting(forwardAndDeliver, "ForwardAndDeliver")} + {getCippFormatting(forwardAndDeliver, 'ForwardAndDeliver')} @@ -221,7 +234,7 @@ export const CippExchangeInfoCard = (props) => { )} - ); + ) })() ) } @@ -240,7 +253,7 @@ export const CippExchangeInfoCard = (props) => { Archive Mailbox Enabled: - {getCippFormatting(exchangeData?.ArchiveMailBox, "ArchiveMailBox")} + {getCippFormatting(exchangeData?.ArchiveMailBox, 'ArchiveMailBox')} {exchangeData?.ArchiveMailBox && ( @@ -254,7 +267,7 @@ export const CippExchangeInfoCard = (props) => { {getCippFormatting( exchangeData?.AutoExpandingArchive, - "AutoExpandingArchive", + 'AutoExpandingArchive' )} @@ -265,7 +278,7 @@ export const CippExchangeInfoCard = (props) => { {exchangeData?.TotalArchiveItemSize != null ? `${exchangeData.TotalArchiveItemSize} GB` - : "N/A"} + : 'N/A'} @@ -275,7 +288,7 @@ export const CippExchangeInfoCard = (props) => { {exchangeData?.TotalArchiveItemCount != null ? exchangeData.TotalArchiveItemCount - : "N/A"} + : 'N/A'} @@ -298,7 +311,7 @@ export const CippExchangeInfoCard = (props) => { key={hold.name} label={hold.name} icon={hold.enabled ? : } - color={hold.enabled ? "success" : "default"} + color={hold.enabled ? 'success' : 'default'} variant="outlined" size="small" sx={{ mr: 1, mb: 1 }} @@ -316,29 +329,42 @@ export const CippExchangeInfoCard = (props) => { ) : (
- {protocols.map((protocol) => ( - : } - color={protocol.enabled ? "success" : "default"} - variant="outlined" - size="small" - sx={{ mr: 1, mb: 1 }} - /> - ))} + {protocols.map((protocol) => { + // For normal protocols, enabled = good (green). SMTP is inverted: + // enabled = risk (red), disabled = good (green). Unknown stays neutral. + const isGood = protocol.riskWhenEnabled ? !protocol.enabled : protocol.enabled + return ( + : } + color={ + protocol.unknown + ? 'default' + : isGood + ? 'success' + : protocol.riskWhenEnabled + ? 'error' + : 'default' + } + variant="outlined" + size="small" + sx={{ mr: 1, mb: 1 }} + /> + ) + })}
) } />
- ); -}; + ) +} CippExchangeInfoCard.propTypes = { exchangeData: PropTypes.object, isLoading: PropTypes.bool, isFetching: PropTypes.bool, handleRefresh: PropTypes.func, -}; +} diff --git a/src/components/CippComponents/AlertsOverviewCard.jsx b/src/components/CippComponents/AlertsOverviewCard.jsx new file mode 100644 index 000000000000..1dec6e1d345a --- /dev/null +++ b/src/components/CippComponents/AlertsOverviewCard.jsx @@ -0,0 +1,310 @@ +import { useMemo, useState } from 'react' +import { + Box, + Button, + Card, + CardContent, + CardHeader, + Chip, + Divider, + IconButton, + Skeleton, + Stack, + Tooltip, + Typography, +} from '@mui/material' +import { + DeleteOutline as DeleteIcon, + NotificationsActive as AlertIcon, + Settings as SettingsIcon, + Snooze as SnoozeIcon, +} from '@mui/icons-material' +import Link from 'next/link' +import { ApiGetCall } from '../../api/ApiCall' +import { getCippError } from '../../utils/get-cipp-error' +import { useDialog } from '../../hooks/use-dialog' +import { CippAlertSnoozeDialog } from './CippAlertSnoozeDialog' +import { CippApiDialog } from './CippApiDialog' +import { describeAlertItem, humanizeCmdlet } from '../../utils/format-alert-item' + +const ACTIVE_SNOOZE_STATUSES = ['Active', 'Forever'] +const rowSx = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 1, + py: 1, +} + +const describeSnooze = (snooze) => { + if (snooze.Status === 'Forever') return 'Snoozed indefinitely' + if (snooze.Status === 'Expired') return 'Snooze expired' + const until = Number(snooze.SnoozeUntil) + const parts = [] + if (typeof snooze.RemainingDays === 'number') parts.push(`${snooze.RemainingDays}d left`) + if (Number.isFinite(until) && until > 0) { + const untilDate = new Date(until * 1000).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }) + parts.push(`until ${untilDate}`) + } + return `Snoozed${parts.length ? ` · ${parts.join(' · ')}` : ''}` +} + +const SnoozeStatusChip = ({ snooze }) => { + if (snooze.Status === 'Forever') { + return } label="Forever" /> + } + if (snooze.Status === 'Expired') { + return + } + return ( + } + label={`${snooze.RemainingDays}d`} + /> + ) +} + +export const AlertsOverviewCard = ({ tenantFilter, sx }) => { + const [snoozeTarget, setSnoozeTarget] = useState(null) + const removeDialog = useDialog() + + const resultsQueryKey = `ListAlertResults-${tenantFilter}` + // Dedicated key — must NOT be "ListSnoozedAlerts": that key is owned by the Snoozed + // Alerts CippDataTable, which fetches it as an infinite query ({ pages }). A plain + // useQuery here under the same key would clobber that cache entry and crash the table. + const snoozeQueryKey = 'ListSnoozedAlerts-DashboardCard' + const relatedQueryKeys = ['ListSnoozedAlerts', snoozeQueryKey, resultsQueryKey] + + const resultsApi = ApiGetCall({ + url: '/api/ListAlertResults', + queryKey: resultsQueryKey, + data: { tenantFilter }, + waiting: !!tenantFilter, + }) + const snoozeApi = ApiGetCall({ url: '/api/ListSnoozedAlerts', queryKey: snoozeQueryKey }) + + const tenantSnoozes = useMemo( + () => + (Array.isArray(snoozeApi.data) ? snoozeApi.data : []).filter( + (snooze) => snooze.Tenant === tenantFilter + ), + [snoozeApi.data, tenantFilter] + ) + + // Content hashes of items that are currently snoozed — used to drop them from the + // active list (a just-snoozed item lingers in AlertLastRun until the alert next runs). + const activeSnoozeHashes = useMemo(() => { + const set = new Set() + tenantSnoozes.forEach((snooze) => { + if (ACTIVE_SNOOZE_STATUSES.includes(snooze.Status) && snooze.ContentHash) { + set.add(snooze.ContentHash) + } + }) + return set + }, [tenantSnoozes]) + + const activeItems = useMemo(() => { + const items = Array.isArray(resultsApi.data) ? resultsApi.data : [] + return items.filter((item) => !activeSnoozeHashes.has(item.ContentHash)) + }, [resultsApi.data, activeSnoozeHashes]) + + const sortedSnoozes = useMemo( + () => + [...tenantSnoozes].sort( + (a, b) => + (ACTIVE_SNOOZE_STATUSES.includes(a.Status) ? 0 : 1) - + (ACTIVE_SNOOZE_STATUSES.includes(b.Status) ? 0 : 1) + ), + [tenantSnoozes] + ) + + const activeSnoozeCount = tenantSnoozes.filter((snooze) => + ACTIVE_SNOOZE_STATUSES.includes(snooze.Status) + ).length + + // A disabled query (no tenant yet) reports isLoading=false in react-query v5, so guard + // on tenantFilter to avoid flashing a false "no alerts" state before the tenant resolves. + const isLoading = !tenantFilter || resultsApi.isLoading || snoozeApi.isLoading + const hasError = resultsApi.isError || snoozeApi.isError + + const renderBody = () => { + if (isLoading) { + return ( + + + + + + + ) + } + + if (hasError) { + return ( + + {getCippError(resultsApi.error || snoozeApi.error)} + + ) + } + + return ( + <> + + } + label={`${activeItems.length} Active`} + /> + } + label={`${activeSnoozeCount} Snoozed`} + /> + + + + {activeItems.length > 0 ? ( + }> + {activeItems.map((item, index) => { + const { title, detail } = describeAlertItem(item.AlertItem, item.ContentPreview) + const label = item.AlertComment?.trim() || humanizeCmdlet(item.CmdletName) + const secondary = detail ? `${label} · ${detail}` : label + return ( + + + + {title} + + + {secondary} + + + + setSnoozeTarget(item)}> + + + + + ) + })} + + ) : ( + + No active alerts for this tenant. + + )} + + {sortedSnoozes.length > 0 && ( + + + Snoozed + + }> + {sortedSnoozes.map((snooze) => { + const { title } = describeAlertItem(null, snooze.ContentPreview) + const status = describeSnooze(snooze) + const by = snooze.SnoozedBy ? ` · by ${snooze.SnoozedBy}` : '' + const secondary = `${humanizeCmdlet(snooze.CmdletName)} · ${status}${by}` + return ( + + + + {title} + + + {secondary} + + + + + + removeDialog.handleOpen(snooze)}> + + + + + + ) + })} + + + )} + + + ) + } + + return ( + + + + Alerts + + } + action={ + + } + sx={{ pb: 1 }} + /> + + {renderBody()} + + setSnoozeTarget(null)} + alertItem={snoozeTarget?.AlertItem} + cmdletName={snoozeTarget?.CmdletName} + tenantFilter={tenantFilter} + relatedQueryKeys={relatedQueryKeys} + /> + + + + ) +} diff --git a/src/components/CippComponents/AppRegistrationActions.jsx b/src/components/CippComponents/AppRegistrationActions.jsx index 0e8b6a204222..ba39dd295a17 100644 --- a/src/components/CippComponents/AppRegistrationActions.jsx +++ b/src/components/CippComponents/AppRegistrationActions.jsx @@ -62,8 +62,12 @@ export const getAppRegistrationPostAndDestructiveActions = (canWriteApplication) }, ], confirmText: - "Create a deployment template from '[displayName]'? This will copy all permissions and create a reusable template. If you run this from a customer tenant, the App Registration will first be copied to the partner tenant as a multi-tenant app.", - condition: (row) => canWriteApplication && !row?.applicationTemplateId, + "'[displayName]' is a multi-tenant app, so a multi-tenant Enterprise App template will be created. This copies all permissions into a reusable template. If you run this from a customer tenant, the App Registration will first be copied to the partner tenant as a multi-tenant app.", + condition: (row) => + canWriteApplication && + !row?.applicationTemplateId && + (row?.signInAudience === 'AzureADMultipleOrgs' || + row?.signInAudience === 'AzureADandPersonalMicrosoftAccount'), }, { icon: , @@ -72,8 +76,6 @@ export const getAppRegistrationPostAndDestructiveActions = (canWriteApplication) color: 'success', multiPost: false, url: '/api/ExecAppApprovalTemplate', - confirmText: - "Create a manifest template from '[displayName]'? This will create a reusable template that can be deployed as a single-tenant app in any tenant.", fields: [ { label: 'Template Name', @@ -115,7 +117,8 @@ export const getAppRegistrationPostAndDestructiveActions = (canWriteApplication) ApplicationManifest: cleanManifest, } }, - confirmText: 'Are you sure you want to create a template from this app registration?', + confirmText: + "'[displayName]' is a single-tenant app, so a single-tenant Application Manifest template will be created. This captures the app manifest into a reusable template that can be deployed to any tenant.", condition: (row) => canWriteApplication && row.signInAudience === 'AzureADMyOrg' && !row?.applicationTemplateId, }, diff --git a/src/components/CippComponents/AuthMethodCard.jsx b/src/components/CippComponents/AuthMethodCard.jsx index 97ade0f8d6c6..4b9dd4b4828e 100644 --- a/src/components/CippComponents/AuthMethodCard.jsx +++ b/src/components/CippComponents/AuthMethodCard.jsx @@ -16,8 +16,23 @@ export const AuthMethodCard = ({ data, isLoading }) => { return null; } - const phishableMethods = ["mobilePhone", "email", "microsoftAuthenticatorPush"]; - const phishResistantMethods = ["fido2", "windowsHelloForBusiness", "x509Certificate"]; + const phishableMethods = [ + "mobilePhone", + "alternateMobilePhone", + "officePhone", + "email", + "microsoftAuthenticatorPush", + "softwareOneTimePasscode", + "hardwareOneTimePasscode", + ]; + const passkeyMethods = [ + "fido2SecurityKey", + "passKeyDeviceBound", + "passKeyDeviceBoundAuthenticator", + "passKeyDeviceBoundWindowsHello", + "x509Certificate", + ]; + const phishResistantMethods = [...passkeyMethods, "windowsHelloForBusiness"]; let singleFactor = 0; let phishableCount = 0; @@ -48,7 +63,7 @@ export const AuthMethodCard = ({ data, isLoading }) => { if (hasPhishResistant) { phishResistantCount++; - if (methods.includes("fido2") || methods.includes("x509Certificate")) { + if (methods.some((m) => passkeyMethods.includes(m))) { passkeyCount++; } if (methods.includes("windowsHelloForBusiness")) { @@ -56,12 +71,18 @@ export const AuthMethodCard = ({ data, isLoading }) => { } } else if (hasPhishable) { phishableCount++; - if (methods.includes("mobilePhone") || methods.includes("email")) { + if ( + methods.includes("mobilePhone") || + methods.includes("alternateMobilePhone") || + methods.includes("officePhone") || + methods.includes("email") + ) { phoneCount++; } if ( methods.includes("microsoftAuthenticatorPush") || - methods.includes("softwareOneTimePasscode") + methods.includes("softwareOneTimePasscode") || + methods.includes("hardwareOneTimePasscode") ) { authenticatorCount++; } @@ -196,7 +217,17 @@ export const AuthMethodCard = ({ data, isLoading }) => { + router.push("/identity/reports/mfa-report")} + sx={{ + display: "flex", + alignItems: "center", + gap: 1, + cursor: "pointer", + width: "fit-content", + "&:hover": { textDecoration: "underline" }, + }} + > All users auth methods diff --git a/src/components/CippComponents/AuthMethodSankey.jsx b/src/components/CippComponents/AuthMethodSankey.jsx index f57c42573c52..f65ec13c1483 100644 --- a/src/components/CippComponents/AuthMethodSankey.jsx +++ b/src/components/CippComponents/AuthMethodSankey.jsx @@ -13,9 +13,23 @@ export const AuthMethodSankey = ({ data }) => { return null; } - // Categorize MFA methods as phishable or phish-resistant - const phishableMethods = ["mobilePhone", "email", "microsoftAuthenticatorPush"]; - const phishResistantMethods = ["fido2", "windowsHelloForBusiness", "x509Certificate"]; + const phishableMethods = [ + "mobilePhone", + "alternateMobilePhone", + "officePhone", + "email", + "microsoftAuthenticatorPush", + "softwareOneTimePasscode", + "hardwareOneTimePasscode", + ]; + const passkeyMethods = [ + "fido2SecurityKey", + "passKeyDeviceBound", + "passKeyDeviceBoundAuthenticator", + "passKeyDeviceBoundWindowsHello", + "x509Certificate", + ]; + const phishResistantMethods = [...passkeyMethods, "windowsHelloForBusiness"]; let singleFactor = 0; let phishableCount = 0; @@ -54,7 +68,7 @@ export const AuthMethodSankey = ({ data }) => { if (hasPhishResistant) { phishResistantCount++; // Count specific phish-resistant methods - if (methods.includes("fido2") || methods.includes("x509Certificate")) { + if (methods.some((m) => passkeyMethods.includes(m))) { passkeyCount++; } if (methods.includes("windowsHelloForBusiness")) { @@ -62,13 +76,18 @@ export const AuthMethodSankey = ({ data }) => { } } else if (hasPhishable) { phishableCount++; - // Count specific phishable methods - if (methods.includes("mobilePhone") || methods.includes("email")) { + if ( + methods.includes("mobilePhone") || + methods.includes("alternateMobilePhone") || + methods.includes("officePhone") || + methods.includes("email") + ) { phoneCount++; } if ( methods.includes("microsoftAuthenticatorPush") || - methods.includes("softwareOneTimePasscode") + methods.includes("softwareOneTimePasscode") || + methods.includes("hardwareOneTimePasscode") ) { authenticatorCount++; } diff --git a/src/components/CippComponents/CippAlertSnoozeDialog.jsx b/src/components/CippComponents/CippAlertSnoozeDialog.jsx index f94e5fe62dae..cc5638433411 100644 --- a/src/components/CippComponents/CippAlertSnoozeDialog.jsx +++ b/src/components/CippComponents/CippAlertSnoozeDialog.jsx @@ -10,10 +10,15 @@ import { Radio, Typography, Box, - Alert, + Stack, } from '@mui/material' import { ApiPostCall } from '../../api/ApiCall' import { CippApiResults } from './CippApiResults' +import { + describeAlertItem, + getAlertItemFields, + humanizeCmdlet, +} from '../../utils/format-alert-item' const SNOOZE_OPTIONS = [ { value: '7', label: 'Snooze for 7 days' }, @@ -56,23 +61,53 @@ export const CippAlertSnoozeDialog = ({ onClose() } - // Build a preview of the alert item - const preview = - alertItem?.UserPrincipalName || - alertItem?.Message || - alertItem?.DisplayName || - (alertItem ? JSON.stringify(alertItem).substring(0, 120) : '') + const fields = getAlertItemFields(alertItem) + const { title } = describeAlertItem(alertItem) + const alertLabel = humanizeCmdlet(cmdletName) return ( Snooze Alert - {preview && ( - - - {preview} + {alertItem && ( + + + {alertLabel} - + {fields.length > 0 ? ( + + {fields.map((field) => ( + + + {field.label} + + + {field.value} + + + ))} + + ) : ( + + {title} + + )} + )} {!submitted ? ( diff --git a/src/components/CippComponents/CippApiDialog.jsx b/src/components/CippComponents/CippApiDialog.jsx index da48eb7e4ccd..a015a4449cc0 100644 --- a/src/components/CippComponents/CippApiDialog.jsx +++ b/src/components/CippComponents/CippApiDialog.jsx @@ -15,6 +15,7 @@ import { useRouter } from "next/router"; import { useForm, useFormState } from "react-hook-form"; import { useSettings } from "../../hooks/use-settings"; import CippFormComponent from "./CippFormComponent"; +import { CippFormCondition } from "./CippFormCondition"; export const CippApiDialog = (props) => { const { @@ -85,6 +86,18 @@ export const CippApiDialog = (props) => { }, }); + // Whenever the dialog is (re)opened, discard any results from a previous run + // so a freshly created window never shows stale output from an earlier action. + // The POST mutation and GET query retain their last result while this component + // stays mounted, so clear both alongside the streamed partial results. + useEffect(() => { + if (createDialog.open) { + setPartialResults([]); + actionPostRequest.reset(); + setGetRequestInfo((prev) => ({ ...prev, waiting: false, queryKey: "" })); + } + }, [createDialog.open]); + const processActionData = (dataObject, row, replacementBehaviour) => { if (typeof api?.dataFunction === "function") return api.dataFunction(row, dataObject); @@ -231,12 +244,15 @@ export const CippApiDialog = (props) => { useEffect(() => { if (api?.setDefaultValues && createDialog.open) { fields.forEach((field) => { - const val = row[field.name]; + const targetName = field.name.replace(/\[(\w+)\]/g, ".$1"); + const val = targetName + .split(".") + .reduce((acc, key) => (acc != null ? acc[key] : undefined), row); if ( (typeof val === "string" && field.type === "textField") || (typeof val === "boolean" && field.type === "switch") ) { - formHook.setValue(field.name, val); + formHook.setValue(targetName, val); } else if (Array.isArray(val) && field.type === "autoComplete") { const values = val .map((el) => @@ -247,10 +263,10 @@ export const CippApiDialog = (props) => { : null, ) .filter(Boolean); - formHook.setValue(field.name, values); + formHook.setValue(targetName, values); } else if (field.type === "autoComplete" && val) { formHook.setValue( - field.name, + targetName, typeof val === "string" ? { label: val, value: val } : val.label && val.value @@ -330,7 +346,7 @@ export const CippApiDialog = (props) => { (_, key) => getNestedValue(row, key) || `[${key}]`, ); } else if (row.length > 1) { - confirmText = api.confirmText.replace(/\[([^\]]+)\]/g, "the selected rows"); + confirmText = api.confirmText.replace(/\[([^\]]+)\]/g, `the ${row.length} selected rows`); } else if (row.length === 1) { confirmText = api.confirmText.replace( /\[([^\]]+)\]/g, @@ -343,7 +359,7 @@ export const CippApiDialog = (props) => { if (typeof element === "string") { if (Array.isArray(row)) { return row.length > 1 - ? element.replace(/\[([^\]]+)\]/g, "the selected rows") + ? element.replace(/\[([^\]]+)\]/g, `the ${row.length} selected rows`) : element.replace( /\[([^\]]+)\]/g, (_, key) => getNestedValue(row[0], key) || `[${key}]`, @@ -382,17 +398,29 @@ export const CippApiDialog = (props) => { ) ) : ( <> - {fields?.map((fieldProps, i) => ( - + {fields?.map((fieldProps, i) => { + const { condition, ...rest } = fieldProps; + const fieldElement = ( - - ))} + ); + return ( + + {condition ? ( + + {fieldElement} + + ) : ( + fieldElement + )} + + ); + })} )} diff --git a/src/components/CippComponents/CippApiResults.jsx b/src/components/CippComponents/CippApiResults.jsx index 12a0ec4be593..17d72ca13923 100644 --- a/src/components/CippComponents/CippApiResults.jsx +++ b/src/components/CippComponents/CippApiResults.jsx @@ -1,4 +1,4 @@ -import { Close, Download, Help, ExpandMore, ExpandLess } from "@mui/icons-material"; +import { Close, Download, Help, ExpandMore, ExpandLess } from '@mui/icons-material' import { Alert, CircularProgress, @@ -11,40 +11,40 @@ import { Tooltip, Button, keyframes, -} from "@mui/material"; -import { useEffect, useState, useMemo, useCallback } from "react"; -import { getCippError } from "../../utils/get-cipp-error"; -import { CippCopyToClipBoard } from "./CippCopyToClipboard"; -import { CippDocsLookup } from "./CippDocsLookup"; -import { CippCodeBlock } from "./CippCodeBlock"; -import React from "react"; -import { CippTableDialog } from "./CippTableDialog"; -import { EyeIcon } from "@heroicons/react/24/outline"; -import { useDialog } from "../../hooks/use-dialog"; +} from '@mui/material' +import { useEffect, useState, useMemo, useCallback } from 'react' +import { getCippError } from '../../utils/get-cipp-error' +import { CippCopyToClipBoard } from './CippCopyToClipboard' +import { CippDocsLookup } from './CippDocsLookup' +import { CippCodeBlock } from './CippCodeBlock' +import React from 'react' +import { CippTableDialog } from './CippTableDialog' +import { EyeIcon } from '@heroicons/react/24/outline' +import { useDialog } from '../../hooks/use-dialog' const extractAllResults = (data) => { - const results = []; + const results = [] const getSeverity = (text) => { - if (typeof text !== "string") return "success"; - return /error|failed|exception|not found|invalid_grant/i.test(text) ? "error" : "success"; - }; + if (typeof text !== 'string') return 'success' + return /error|failed|exception|not found|invalid_grant/i.test(text) ? 'error' : 'success' + } const processResultItem = (item) => { - if (typeof item === "string") { + if (typeof item === 'string') { return { text: item, copyField: item, severity: getSeverity(item), - }; + } } - if (item && typeof item === "object") { - const text = item.resultText || ""; - const copyField = item.copyField || ""; + if (item && typeof item === 'object') { + const text = item.resultText || '' + const copyField = item.copyField || '' const severity = - typeof item.state === "string" ? item.state : getSeverity(item) ? "error" : "success"; - const details = item.details || null; + typeof item.state === 'string' ? item.state : getSeverity(item) ? 'error' : 'success' + const details = item.details || null if (text) { return { @@ -53,144 +53,158 @@ const extractAllResults = (data) => { severity, details, ...item, - }; + } } } - return null; - }; + return null + } const extractFrom = (obj) => { - if (!obj) return; + if (!obj) return if (Array.isArray(obj)) { - obj.forEach((item) => extractFrom(item)); - return; + obj.forEach((item) => extractFrom(item)) + return } - if (typeof obj === "string") { - results.push({ text: obj, copyField: obj, severity: getSeverity(obj) }); - return; + if (typeof obj === 'string') { + results.push({ text: obj, copyField: obj, severity: getSeverity(obj) }) + return } if (obj?.resultText) { - const processed = processResultItem(obj); + const processed = processResultItem(obj) if (processed) { - results.push(processed); + results.push(processed) } } else { - const ignoreKeys = ["metadata", "Metadata", "severity"]; + const ignoreKeys = ['metadata', 'Metadata', 'severity'] - if (typeof obj === "object") { + if (typeof obj === 'object') { Object.keys(obj).forEach((key) => { - const value = obj[key]; - if (ignoreKeys.includes(key)) return; - if (["Results", "Result", "results", "result"].includes(key)) { + const value = obj[key] + if (ignoreKeys.includes(key)) return + if (['Results', 'Result', 'results', 'result'].includes(key)) { if (Array.isArray(value)) { value.forEach((valItem) => { - const processed = processResultItem(valItem); + const processed = processResultItem(valItem) if (processed) { - results.push(processed); + results.push(processed) } else { - extractFrom(valItem); + extractFrom(valItem) } - }); - } else if (typeof value === "object") { - const processed = processResultItem(value); + }) + } else if (typeof value === 'object') { + const processed = processResultItem(value) if (processed) { - results.push(processed); + results.push(processed) } else { - extractFrom(value); + extractFrom(value) } - } else if (typeof value === "string") { + } else if (typeof value === 'string') { results.push({ text: value, copyField: value, severity: getSeverity(value), - }); + }) } } else { - extractFrom(value); + extractFrom(value) } - }); + }) } } - }; + } - extractFrom(data); - return results; -}; + extractFrom(data) + return results +} export const CippApiResults = (props) => { - const { apiObject, errorsOnly = false, alertSx = {} } = props; + const { apiObject, errorsOnly = false, alertSx = {} } = props - const [errorVisible, setErrorVisible] = useState(false); - const [fetchingVisible, setFetchingVisible] = useState(false); - const [finalResults, setFinalResults] = useState([]); - const [showDetails, setShowDetails] = useState({}); - const tableDialog = useDialog(); - const pageTitle = `${document.title} - Results`; + const [errorVisible, setErrorVisible] = useState(false) + const [fetchingVisible, setFetchingVisible] = useState(false) + const [finalResults, setFinalResults] = useState([]) + const [showDetails, setShowDetails] = useState({}) + const tableDialog = useDialog() + const pageTitle = `${document.title} - Results` const correctResultObj = useMemo(() => { - if (!apiObject.isSuccess) return; + if (!apiObject.isSuccess) return - const data = apiObject?.data; - const dataData = data?.data; + const data = apiObject?.data + const dataData = data?.data if (dataData !== undefined && dataData !== null) { if (dataData?.Results) { - return dataData.Results; - } else if (typeof dataData === "object" && dataData !== null && !("metadata" in dataData)) { - return dataData; - } else if (typeof dataData === "string") { - return dataData; + return dataData.Results + } else if (typeof dataData === 'object' && dataData !== null && !('metadata' in dataData)) { + return dataData + } else if (typeof dataData === 'string') { + return dataData } else { - return "This API has not sent the correct output format."; + return 'This API has not sent the correct output format.' } } if (data?.Results) { - return data.Results; - } else if (typeof data === "object" && data !== null && !("metadata" in data)) { - return data; - } else if (typeof data === "string") { - return data; + return data.Results + } else if (typeof data === 'object' && data !== null && !('metadata' in data)) { + return data + } else if (typeof data === 'string') { + return data } - return "This API has not sent the correct output format."; - }, [apiObject]); + return 'This API has not sent the correct output format.' + }, [apiObject]) const allResults = useMemo(() => { - const apiResults = extractAllResults(correctResultObj); + const sourceItems = Array.isArray(correctResultObj) ? correctResultObj : [correctResultObj] + const apiResults = sourceItems.flatMap((item, groupIndex) => + extractAllResults(item).map((r) => ({ ...r, groupIndex })) + ) // Also extract error results if there's an error if (apiObject.isError && apiObject.error) { - const errorResults = extractAllResults(apiObject.error.response.data); + const errorData = apiObject.error.response?.data + const errorItems = Array.isArray(errorData) ? errorData : [errorData] + const errorResults = errorItems.flatMap((item, index) => + extractAllResults(item).map((r) => ({ + ...r, + severity: 'error', + groupIndex: sourceItems.length + index, + })) + ) if (errorResults.length > 0) { // Mark all error results with error severity and merge with success results - return [...apiResults, ...errorResults.map((r) => ({ ...r, severity: "error" }))]; + return [...apiResults, ...errorResults] } // Fallback to getCippError if extraction didn't work - const processedError = getCippError(apiObject.error); - if (typeof processedError === "string") { + const processedError = getCippError(apiObject.error) + if (typeof processedError === 'string') { return [ ...apiResults, - { text: processedError, copyField: processedError, severity: "error" }, - ]; + { + text: processedError, + copyField: processedError, + severity: 'error', + groupIndex: sourceItems.length, + }, + ] } } - return apiResults; - }, [correctResultObj, apiObject.isError, apiObject.error]); + return apiResults + }, [correctResultObj, apiObject.isError, apiObject.error]) useEffect(() => { - setErrorVisible(!!apiObject.isError); + setErrorVisible(!!apiObject.isError) if (apiObject.isFetching || (apiObject.isIdle === false && apiObject.isPending === true)) { - setFetchingVisible(true); + setFetchingVisible(true) } else { - setFetchingVisible(false); + setFetchingVisible(false) } - const resultsToShow = errorsOnly - ? allResults.filter((r) => r.severity === "error") - : allResults; + const resultsToShow = errorsOnly ? allResults.filter((r) => r.severity === 'error') : allResults if (resultsToShow.length > 0) { setFinalResults( @@ -201,10 +215,10 @@ export const CippApiResults = (props) => { severity: res.severity, visible: true, ...res, - })), - ); + })) + ) } else { - setFinalResults([]); + setFinalResults([]) } }, [ apiObject.isError, @@ -213,38 +227,44 @@ export const CippApiResults = (props) => { apiObject.isIdle, allResults, errorsOnly, - ]); + ]) const handleCloseResult = useCallback((id) => { - setFinalResults((prev) => prev.map((r) => (r.id === id ? { ...r, visible: false } : r))); - }, []); + setFinalResults((prev) => prev.map((r) => (r.id === id ? { ...r, visible: false } : r))) + }, []) const toggleDetails = useCallback((id) => { - setShowDetails((prev) => ({ ...prev, [id]: !prev[id] })); - }, []); + setShowDetails((prev) => ({ ...prev, [id]: !prev[id] })) + }, []) const handleDownloadCsv = useCallback(() => { - if (!finalResults?.length) return; + if (!finalResults?.length) return - const baseName = document.title.toLowerCase().replace(/[^a-z0-9]/g, "-"); - const fileName = `${baseName}-results.csv`; + const baseName = document.title.toLowerCase().replace(/[^a-z0-9]/g, '-') + const fileName = `${baseName}-results.csv` - const headers = Object.keys(finalResults[0]); + const headers = Object.keys(finalResults[0]) const rows = finalResults.map((item) => - headers.map((header) => `"${item[header] || ""}"`).join(","), - ); - const csvContent = [headers.join(","), ...rows].join("\n"); - const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.setAttribute("href", url); - link.setAttribute("download", fileName); - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - }, [finalResults, apiObject]); + headers.map((header) => `"${item[header] || ''}"`).join(',') + ) + const csvContent = [headers.join(','), ...rows].join('\n') + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.setAttribute('href', url) + link.setAttribute('download', fileName) + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + }, [finalResults, apiObject]) - const hasVisibleResults = finalResults.some((r) => r.visible); + const hasVisibleResults = finalResults.some((r) => r.visible) + const actionGroups = [...new Set(finalResults.map((r) => r.groupIndex ?? r.id))] + const actionCount = actionGroups.length + const failedActionCount = actionGroups.filter((group) => + finalResults.some((r) => (r.groupIndex ?? r.id) === group && r.severity === 'error') + ).length + const successActionCount = actionCount - failedActionCount return ( {/* Loading alert */} @@ -271,6 +291,24 @@ export const CippApiResults = (props) => { )} + {/* Summary rollup for bulk results */} + {!errorsOnly && hasVisibleResults && actionCount > 1 && ( + + + {failedActionCount === 0 + ? `All ${actionCount} actions completed successfully` + : `${failedActionCount} of ${actionCount} actions failed${ + successActionCount > 0 ? `, ${successActionCount} succeeded` : '' + }`} + + + )} {/* Individual result alerts */} {hasVisibleResults && ( <> @@ -280,24 +318,24 @@ export const CippApiResults = (props) => { - {resultObj.severity === "error" && ( + {resultObj.severity === 'error' && ( + + + + + + ); +}; + +export default CippDateRangeFilter; diff --git a/src/components/CippComponents/CippDeployCompliancePolicyDrawer.jsx b/src/components/CippComponents/CippDeployCompliancePolicyDrawer.jsx index c03c976f9f94..9ca5af4741e1 100644 --- a/src/components/CippComponents/CippDeployCompliancePolicyDrawer.jsx +++ b/src/components/CippComponents/CippDeployCompliancePolicyDrawer.jsx @@ -67,10 +67,13 @@ const MODE_CONFIG = { "Tooltip": "Confidential data, do not share externally", "Comment": "Internal-only confidential classification", "ContentType": "File, Email", + "ApplyContentMarkingHeaderEnabled": true, + "ApplyContentMarkingHeaderText": "Confidential - Internal Use Only", + "ApplyContentMarkingHeaderFontColor": "#FF0000", "EncryptionEnabled": true, - "EncryptionProtectionType": "Template", - "ContentMarkingHeaderEnabled": true, - "ContentMarkingHeaderText": "Confidential - Internal Use Only", + "EncryptionProtectionType": "UserDefined", + "EncryptionPromptUser": true, + "EncryptionDoNotForward": true, "PolicyParams": { "Name": "Confidential Label Policy", "ExchangeLocation": "All", diff --git a/src/components/CippComponents/CippDevOptions.jsx b/src/components/CippComponents/CippDevOptions.jsx index 7bddbbbc126a..5d1951eb3d9e 100644 --- a/src/components/CippComponents/CippDevOptions.jsx +++ b/src/components/CippComponents/CippDevOptions.jsx @@ -1,6 +1,15 @@ import { useSettings } from "../../hooks/use-settings"; -import { Button, Card, CardHeader, Divider, CardContent, SvgIcon } from "@mui/material"; -import { CodeBracketIcon } from "@heroicons/react/24/outline"; +import { + Button, + Card, + CardHeader, + Divider, + CardContent, + Stack, + SvgIcon, + Typography, +} from "@mui/material"; +import { CodeBracketIcon, BeakerIcon } from "@heroicons/react/24/outline"; export const CippDevOptions = () => { const settings = useSettings(); @@ -11,22 +20,45 @@ export const CippDevOptions = () => { }); }; + const handleAdvancedToggle = () => { + settings.handleUpdate({ + showAdvancedTools: !settings.showAdvancedTools, + }); + }; + return ( - + + + + + + Advanced Views reveal diagnostic pages (such as audit-log Search Coverage) that are hidden + from day-to-day operations. This preference is per-user, stored in this browser. + ); diff --git a/src/components/CippComponents/CippFormComponent.jsx b/src/components/CippComponents/CippFormComponent.jsx index 628a9aa0dafc..d8b2f9d48443 100644 --- a/src/components/CippComponents/CippFormComponent.jsx +++ b/src/components/CippComponents/CippFormComponent.jsx @@ -25,6 +25,25 @@ import { CippDataTable } from "../CippTable/CippDataTable"; import React from "react"; import { CloudUpload } from "@mui/icons-material"; import { Stack } from "@mui/system"; +import countryList from "../../data/countryList"; +import languageList from "../../data/languageList"; + +// ISO 3166-1 alpha-2 country/region codes (uppercase), used by the CountryCodeMultiSelect type. +const countryCodeOptions = countryList + .map((c) => ({ label: `${c.Name} (${c.Code})`, value: c.Code })) + .sort((a, b) => a.label.localeCompare(b.label)); + +// ISO 639-1 alpha-2 language codes (lowercase), used by the LanguageCodeMultiSelect type. +// Derived from the locale tags in languageList.json, deduplicated to the two-letter primary subtag (e.g. "en-US" -> "en"). +const languageCodeOptions = Object.values( + languageList.reduce((acc, entry) => { + const code = entry.tag?.split("-")[0]?.toLowerCase(); + if (code && code.length === 2 && !acc[code]) { + acc[code] = { label: `${entry.language} (${code})`, value: code }; + } + return acc; + }, {}), +).sort((a, b) => a.label.localeCompare(b.label)); // The tiptap / prosemirror / mui-tiptap editor tree is large and only used by `richText` fields. // Load it on demand via next/dynamic so it is code-split into an async chunk instead of being @@ -87,6 +106,59 @@ export const CippFormComponent = (props) => { } }; + // Shared renderer for autoComplete-backed fields (autoComplete + the ISO-code multiselects). + const renderAutoCompleteField = (autoCompleteProps) => { + // Resolve options if it's a function + const resolvedOptions = + typeof autoCompleteProps.options === "function" + ? autoCompleteProps.options(row) + : autoCompleteProps.options; + + // Wrap validate function to pass row as third parameter + const resolvedValidators = validators + ? { + ...validators, + validate: + typeof validators.validate === "function" + ? (value, formValues) => validators.validate(value, formValues, row) + : validators.validate, + } + : validators; + + return ( +
+ ( + field.onChange(value)} + onBlur={field.onBlur} + /> + )} + /> + + {get(errors, convertedName, {})?.message && ( + + {get(errors, convertedName, {})?.message} + + )} + {helperText && ( + + {helperText} + + )} +
+ ); + }; + switch (type) { case "heading": return ( @@ -434,55 +506,26 @@ export const CippFormComponent = (props) => { ); - case "autoComplete": { - // Resolve options if it's a function - const resolvedOptions = - typeof other.options === "function" ? other.options(row) : other.options; - - // Wrap validate function to pass row as third parameter - const resolvedValidators = validators - ? { - ...validators, - validate: - typeof validators.validate === "function" - ? (value, formValues) => validators.validate(value, formValues, row) - : validators.validate, - } - : validators; + case "autoComplete": + return renderAutoCompleteField(other); - return ( -
- ( - field.onChange(value)} - onBlur={field.onBlur} - /> - )} - /> + // ISO 3166-1 alpha-2 region/country code multiselect (e.g. Spam Filter RegionBlockList). + case "CountryCodeMultiSelect": + return renderAutoCompleteField({ + ...other, + options: countryCodeOptions, + multiple: true, + creatable: false, + }); - {get(errors, convertedName, {})?.message && ( - - {get(errors, convertedName, {})?.message} - - )} - {helperText && ( - - {helperText} - - )} -
- ); - } + // ISO 639-1 alpha-2 language code multiselect (e.g. Spam Filter LanguageBlockList). + case "LanguageCodeMultiSelect": + return renderAutoCompleteField({ + ...other, + options: languageCodeOptions, + multiple: true, + creatable: false, + }); case "richText": { return ( diff --git a/src/components/CippComponents/CippFormUserAndGroupSelector.jsx b/src/components/CippComponents/CippFormUserAndGroupSelector.jsx index b2fef52aa1a5..ddb0992eeab8 100644 --- a/src/components/CippComponents/CippFormUserAndGroupSelector.jsx +++ b/src/components/CippComponents/CippFormUserAndGroupSelector.jsx @@ -30,12 +30,13 @@ export const CippFormUserAndGroupSelector = ({ url: "/api/ListUsersAndGroups", dataKey: "Results", labelField: (option) => { - // If it's a group (no userPrincipalName), just show displayName - if (!option.userPrincipalName) { - return `${option.displayName}`; - } - // If it's a user, show displayName and userPrincipalName - return `${option.displayName} (${option.userPrincipalName})`; + if (option.userPrincipalName) return `${option.displayName} (${option.userPrincipalName})`; + const groupType = option.mailEnabled && !option.securityEnabled + ? "Distribution Group" + : option.mailEnabled && option.securityEnabled + ? "Mail-Enabled Security Group" + : "Security Group"; + return `${option.displayName} (${groupType})`; }, valueField: valueField ? valueField : "id", queryKey: `ListUsersAndGroups-${ @@ -52,13 +53,6 @@ export const CippFormUserAndGroupSelector = ({ }, showRefresh: showRefresh, }} - groupBy={(option) => { - // Group by type - Users or Groups - if (option["@odata.type"] === "#microsoft.graph.group") { - return "Groups"; - } - return "Users"; - }} creatable={false} {...other} /> diff --git a/src/components/CippComponents/CippIntunePolicyActions.jsx b/src/components/CippComponents/CippIntunePolicyActions.jsx index fb7363ce14db..def812399959 100644 --- a/src/components/CippComponents/CippIntunePolicyActions.jsx +++ b/src/components/CippComponents/CippIntunePolicyActions.jsx @@ -18,6 +18,11 @@ const assignmentFilterTypeOptions = [ { label: 'Exclude - Apply policy to devices NOT matching filter', value: 'exclude' }, ] +const assignmentDirectionOptions = [ + { label: 'Include these group(s)', value: 'include' }, + { label: 'Exclude these group(s)', value: 'exclude' }, +] + /** * Get assignment actions for Intune policies * @param {string} tenant - The tenant filter @@ -43,16 +48,57 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) => templateData = null, } = options - const getAssignmentFields = () => [ + // Group picker (by ID) reused for both include and exclude selection + const getGroupPickerField = (name, label, required) => ({ + type: 'autoComplete', + name, + label, + multiple: true, + creatable: false, + allowResubmit: true, + ...(required && { validators: { required: 'Please select at least one group' } }), + api: { + url: '/api/ListGraphRequest', + dataKey: 'Results', + queryKey: `ListPolicyAssignmentGroups-${tenant}`, + labelField: (group) => (group.id ? `${group.displayName} (${group.id})` : group.displayName), + valueField: 'id', + addedField: { + description: 'description', + }, + data: { + Endpoint: 'groups', + manualPagination: true, + $select: 'id,displayName,description', + $orderby: 'displayName', + $top: 999, + $count: true, + }, + }, + }) + + // Assignment mode + optional device filter, shared by every assign action. + const getOptionsAndFilterFields = (modeHelperText) => [ + { + type: 'heading', + label: 'Assignment options', + }, { type: 'radio', name: 'assignmentMode', label: 'Assignment mode', options: assignmentModeOptions, defaultValue: 'replace', + // Re-validate the Custom Group picker (no-op for broad actions, which have no groupTargets). + validators: { deps: ['groupTargets'] }, helperText: + modeHelperText || 'Replace will overwrite existing assignments. Append keeps current assignments and adds/overwrites only for the selected groups.', }, + { + type: 'heading', + label: 'Device filter (optional)', + }, { type: 'autoComplete', name: 'assignmentFilter', @@ -73,12 +119,56 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) => options: assignmentFilterTypeOptions, defaultValue: 'include', helperText: 'Choose whether to include or exclude devices matching the filter.', + condition: { field: 'assignmentFilter', compareType: 'hasValue', clearOnHide: false }, }, + ] + + // All Users / All Devices / Globally: fixed target, with an optional exclude-groups picker. + const getBroadAssignFields = () => [ { - type: 'textField', - name: 'excludeGroup', - label: 'Exclude Group Names separated by comma. Wildcards (*) are allowed', + type: 'heading', + label: 'Exclude groups (optional)', }, + getGroupPickerField('excludeGroupTargets', 'Exclude group(s)', false), + ...getOptionsAndFilterFields(), + ] + + // Custom Group: one picker + a radio choosing whether those groups are included or excluded. + const getCustomGroupFields = () => [ + { + type: 'heading', + label: 'Target groups', + }, + { + ...getGroupPickerField('groupTargets', 'Group(s)', false), + helperText: 'Leave empty with Exclude + Replace to remove all exclusions (keeps includes).', + validators: { + // Required, except Exclude + Replace where an empty selection clears all exclusions. + validate: (value, formValues) => { + if ( + formValues?.assignmentDirection === 'exclude' && + (formValues?.assignmentMode || 'replace') === 'replace' + ) { + return true + } + return (Array.isArray(value) && value.length > 0) || 'Please select at least one group' + }, + }, + }, + { + type: 'radio', + name: 'assignmentDirection', + label: 'Assignment direction', + options: assignmentDirectionOptions, + defaultValue: 'include', + // Re-validate the picker so the empty-allowed rule updates when direction changes. + validators: { deps: ['groupTargets'] }, + helperText: + 'Include assigns to these groups; Exclude excludes them. Replace updates only this direction and keeps the other (and All Users/All Devices) intact.', + }, + ...getOptionsAndFilterFields( + 'Replace updates only the selected direction and keeps the other direction plus All Users/All Devices. Append adds the selected groups to existing assignments.' + ), ] const getCustomDataFormatter = (assignTo) => (row, action, formData) => { @@ -90,7 +180,8 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) => ...(platformType && { platformType }), AssignTo: assignTo, assignmentMode: formData?.assignmentMode || 'replace', - excludeGroup: formData?.excludeGroup || null, + ExcludeGroupIds: (formData?.excludeGroupTargets || []).map((g) => g.value).filter(Boolean), + ExcludeGroupNames: (formData?.excludeGroupTargets || []).map((g) => g.label).filter(Boolean), AssignmentFilterName: formData?.assignmentFilter?.value || null, AssignmentFilterType: formData?.assignmentFilter?.value ? formData?.assignmentFilterType || 'include' @@ -101,15 +192,20 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) => const getCustomDataFormatterForGroups = () => (row, action, formData) => { const rows = Array.isArray(row) ? row : [row] const selectedGroups = Array.isArray(formData?.groupTargets) ? formData.groupTargets : [] + const isExclude = formData?.assignmentDirection === 'exclude' + const ids = selectedGroups.map((group) => group.value).filter(Boolean) + const names = selectedGroups.map((group) => group.label).filter(Boolean) return rows.map((item) => ({ tenantFilter: tenant === 'AllTenants' && item?.Tenant ? item.Tenant : tenant, ID: item?.id, type: item?.URLName || policyType, ...(platformType && { platformType }), - GroupIds: selectedGroups.map((group) => group.value).filter(Boolean), - GroupNames: selectedGroups.map((group) => group.label).filter(Boolean), + GroupIds: isExclude ? [] : ids, + GroupNames: isExclude ? [] : names, + ExcludeGroupIds: isExclude ? ids : [], + ExcludeGroupNames: isExclude ? names : [], + assignmentDirection: formData?.assignmentDirection || 'include', assignmentMode: formData?.assignmentMode || 'replace', - excludeGroup: formData?.excludeGroup || null, AssignmentFilterName: formData?.assignmentFilter?.value || null, AssignmentFilterType: formData?.assignmentFilter?.value ? formData?.assignmentFilterType || 'include' @@ -210,6 +306,7 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) => label: 'Assign to All Users', type: 'POST', url: '/api/ExecAssignPolicy', + allowResubmit: true, data: { AssignTo: 'allLicensedUsers', ID: 'id', @@ -217,7 +314,7 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) => ...(platformType && { platformType: '!deviceAppManagement' }), }, multiPost: false, - fields: getAssignmentFields(), + fields: getBroadAssignFields(), customDataformatter: getCustomDataFormatter('allLicensedUsers'), confirmText: 'Are you sure you want to assign "[displayName]" to all users?', icon: , @@ -229,6 +326,7 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) => label: 'Assign to All Devices', type: 'POST', url: '/api/ExecAssignPolicy', + allowResubmit: true, data: { AssignTo: 'AllDevices', ID: 'id', @@ -236,7 +334,7 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) => ...(platformType && { platformType: '!deviceAppManagement' }), }, multiPost: false, - fields: getAssignmentFields(), + fields: getBroadAssignFields(), customDataformatter: getCustomDataFormatter('AllDevices'), confirmText: 'Are you sure you want to assign "[displayName]" to all devices?', icon: , @@ -248,6 +346,7 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) => label: 'Assign Globally (All Users / All Devices)', type: 'POST', url: '/api/ExecAssignPolicy', + allowResubmit: true, data: { AssignTo: 'AllDevicesAndUsers', ID: 'id', @@ -255,7 +354,7 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) => ...(platformType && { platformType: '!deviceAppManagement' }), }, multiPost: false, - fields: getAssignmentFields(), + fields: getBroadAssignFields(), customDataformatter: getCustomDataFormatter('AllDevicesAndUsers'), confirmText: 'Are you sure you want to assign "[displayName]" to all users and devices?', icon: , @@ -267,41 +366,12 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) => label: 'Assign to Custom Group', type: 'POST', url: '/api/ExecAssignPolicy', + allowResubmit: true, icon: , color: 'info', confirmText: 'Select the target groups for "[displayName]".', multiPost: false, - fields: [ - { - type: 'autoComplete', - name: 'groupTargets', - label: 'Group(s)', - multiple: true, - creatable: false, - allowResubmit: true, - validators: { required: 'Please select at least one group' }, - api: { - url: '/api/ListGraphRequest', - dataKey: 'Results', - queryKey: `ListPolicyAssignmentGroups-${tenant}`, - labelField: (group) => - group.id ? `${group.displayName} (${group.id})` : group.displayName, - valueField: 'id', - addedField: { - description: 'description', - }, - data: { - Endpoint: 'groups', - manualPagination: true, - $select: 'id,displayName,description', - $orderby: 'displayName', - $top: 999, - $count: true, - }, - }, - }, - ...getAssignmentFields(), - ], + fields: getCustomGroupFields(), customDataformatter: getCustomDataFormatterForGroups(), }) diff --git a/src/components/CippComponents/CippMessageDeliveryInfo.jsx b/src/components/CippComponents/CippMessageDeliveryInfo.jsx new file mode 100644 index 000000000000..032e2178f549 --- /dev/null +++ b/src/components/CippComponents/CippMessageDeliveryInfo.jsx @@ -0,0 +1,241 @@ +import React, { useMemo } from "react"; +import { + Card, + CardContent, + CardHeader, + Chip, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, + Typography, +} from "@mui/material"; +import { Box, Stack } from "@mui/system"; + +// Split the raw email source into its header section and unfold RFC 5322 folded +// headers (continuation lines that begin with whitespace belong to the previous +// header). +const getUnfoldedHeaderLines = (source) => { + if (!source || typeof source !== "string") return []; + const headerEnd = source.search(/\r?\n\r?\n/); + const headerSection = headerEnd === -1 ? source : source.slice(0, headerEnd); + const lines = headerSection.split(/\r?\n/); + const unfolded = []; + for (const line of lines) { + if (/^[ \t]/.test(line) && unfolded.length) { + unfolded[unfolded.length - 1] += " " + line.trim(); + } else { + unfolded.push(line); + } + } + return unfolded; +}; + +const getHeaderValues = (lines, name) => { + const prefix = new RegExp(`^${name}\\s*:`, "i"); + return lines.filter((l) => prefix.test(l)).map((l) => l.replace(prefix, "").trim()); +}; + +const isValidDate = (d) => d instanceof Date && !isNaN(d); + +const parseHop = (raw) => { + const lastSemi = raw.lastIndexOf(";"); + const dateStr = lastSemi !== -1 ? raw.slice(lastSemi + 1).trim() : null; + // Strip trailing parenthetical timezone notes like "(UTC)" that break Date(). + const cleanedDate = dateStr ? dateStr.replace(/\s*\([^)]*\)\s*$/, "").trim() : null; + const date = cleanedDate ? new Date(cleanedDate) : null; + return { + from: raw.match(/\bfrom\s+([^\s;]+)/i)?.[1] ?? null, + by: raw.match(/\bby\s+([^\s;]+)/i)?.[1] ?? null, + with: raw.match(/\bwith\s+([^\s;()]+)/i)?.[1] ?? null, + for: raw.match(/\bfor\s+]+)>?/i)?.[1] ?? null, + date: isValidDate(date) ? date : null, + raw, + }; +}; + +const formatDelay = (ms) => { + if (ms == null || isNaN(ms)) return "—"; + if (ms < 0) ms = 0; + const s = Math.round(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + const rs = s % 60; + if (m < 60) return rs ? `${m}m ${rs}s` : `${m}m`; + const h = Math.floor(m / 60); + const rm = m % 60; + return rm ? `${h}h ${rm}m` : `${h}h`; +}; + +const authColor = (result) => { + switch ((result || "").toLowerCase()) { + case "pass": + return "success"; + case "fail": + case "hardfail": + return "error"; + case "softfail": + case "neutral": + case "none": + case "temperror": + case "permerror": + return "warning"; + default: + return "default"; + } +}; + +export const CippMessageDeliveryInfo = ({ emailSource }) => { + const { hops, totalMs, auth } = useMemo(() => { + const lines = getUnfoldedHeaderLines(emailSource); + + // Received headers are prepended by each MTA, so the raw order is + // newest-first. Reverse to get chronological (oldest) order. + const received = getHeaderValues(lines, "Received") + .map(parseHop) + .reverse(); + + // Delay for hop i is the time between the previous hop and this one. + let total = null; + if (received.length > 1) { + const first = received[0].date; + const last = received[received.length - 1].date; + if (isValidDate(first) && isValidDate(last)) total = last - first; + } + for (let i = 0; i < received.length; i++) { + const prev = received[i - 1]?.date; + const cur = received[i].date; + received[i].delayMs = + i > 0 && isValidDate(prev) && isValidDate(cur) ? cur - prev : null; + } + + // Combine every Authentication-Results / ARC-Authentication-Results value. + const authText = [ + ...getHeaderValues(lines, "Authentication-Results"), + ...getHeaderValues(lines, "ARC-Authentication-Results"), + ].join("; "); + const grab = (key) => authText.match(new RegExp(`\\b${key}=(\\w+)`, "i"))?.[1] ?? null; + const authResults = { + SPF: grab("spf"), + DKIM: grab("dkim"), + DMARC: grab("dmarc"), + CompAuth: grab("compauth"), + ARC: grab("arc"), + }; + + return { hops: received, totalMs: total, auth: authResults }; + }, [emailSource]); + + const authEntries = Object.entries(auth).filter(([, v]) => v); + const hasHops = hops.length > 0; + + // Nothing worth showing (e.g. a body-only message with no Received chain). + if (!hasHops && authEntries.length === 0) return null; + + const maxDelay = Math.max(0, ...hops.map((h) => h.delayMs ?? 0)); + + return ( + + Delivery Information} + action={ + totalMs != null ? ( + + ) : null + } + /> + + {authEntries.length > 0 && ( + + {authEntries.map(([label, result]) => ( + + ))} + + )} + + {hasHops && ( + + + + + # + Delay + From + By + With + Time + + + + {hops.map((hop, index) => ( + + {index + 1} + + + + 0 && hop.delayMs + ? `${Math.max(4, (hop.delayMs / maxDelay) * 100)}%` + : "0%", + backgroundColor: + hop.delayMs > 10000 ? "warning.main" : "primary.main", + }} + /> + + {formatDelay(hop.delayMs)} + + + + + {hop.from ?? "—"} + + + + {hop.by ?? "—"} + + + {hop.with ?? "—"} + + + + {hop.date ? hop.date.toLocaleString() : "—"} + + + + ))} + +
+
+ )} +
+
+ ); +}; + +export default CippMessageDeliveryInfo; diff --git a/src/components/CippComponents/CippMessageViewer.jsx b/src/components/CippComponents/CippMessageViewer.jsx index 557f63daa7af..15ed513cd143 100644 --- a/src/components/CippComponents/CippMessageViewer.jsx +++ b/src/components/CippComponents/CippMessageViewer.jsx @@ -16,6 +16,10 @@ import { DialogContent, IconButton, Tooltip, + TextField, + ToggleButton, + ToggleButtonGroup, + Collapse, } from "@mui/material"; import { Box, Grid, Stack, ThemeProvider } from "@mui/system"; import { createTheme } from "@mui/material/styles"; @@ -37,6 +41,8 @@ import { AccountCircle, Close, ReceiptLong, + ExpandLess, + ExpandMore, } from "@mui/icons-material"; import { CippTimeAgo } from "./CippTimeAgo"; @@ -53,6 +59,7 @@ import { } from "@heroicons/react/24/outline"; import { useSettings } from "../../hooks/use-settings"; import CippForefrontHeaderDialog from "./CippForefrontHeaderDialog"; +import { CippMessageDeliveryInfo } from "./CippMessageDeliveryInfo"; export const CippMessageViewer = ({ emailSource }) => { const [emlContent, setEmlContent] = useState(null); @@ -308,6 +315,8 @@ export const CippMessageViewer = ({ emailSource }) => { return ( <> + + {emlError && ( @@ -549,6 +558,10 @@ export const CippMessageViewer = ({ emailSource }) => { const CippMessageViewerPage = () => { const [emlFile, setEmlFile] = useState(null); + const [inputMode, setInputMode] = useState("upload"); + const [pasteValue, setPasteValue] = useState(""); + const [pasteCollapsed, setPasteCollapsed] = useState(false); + const onDrop = useCallback((acceptedFiles) => { acceptedFiles.forEach((file) => { const reader = new FileReader(); @@ -561,14 +574,85 @@ const CippMessageViewerPage = () => { }); }, []); + const handleModeChange = (event, newMode) => { + if (newMode !== null) { + setInputMode(newMode); + setEmlFile(null); + setPasteCollapsed(false); + } + }; + + const handleAnalyze = () => { + setEmlFile(pasteValue); + setPasteCollapsed(true); + }; + return ( - + + + Upload EML + Paste headers / source + + + {inputMode === "paste" && ( + + + + setPasteCollapsed((prev) => !prev)}> + + {pasteCollapsed ? : } + + + + + )} + + + {inputMode === "upload" ? ( + + ) : ( + + setPasteValue(e.target.value)} + placeholder="Paste raw email headers or the full message source here" + slotProps={{ input: { sx: { fontFamily: "monospace", fontSize: "0.8rem" } } }} + /> + + )} + {emlFile && } ); diff --git a/src/components/CippComponents/CippNotificationForm.jsx b/src/components/CippComponents/CippNotificationForm.jsx index f2f74b19d7f1..03ecce096ec9 100644 --- a/src/components/CippComponents/CippNotificationForm.jsx +++ b/src/components/CippComponents/CippNotificationForm.jsx @@ -42,7 +42,6 @@ export const CippNotificationForm = ({ { label: "Adding a group", value: "AddGroup" }, { label: "Adding a tenant", value: "NewTenant" }, { label: "Executing the offboard wizard", value: "ExecOffboardUser" }, - { label: "Custom Test Alerts", value: "CustomTests" }, ]; const severityTypes = [ diff --git a/src/components/CippComponents/CippOffboardingDefaultSettings.jsx b/src/components/CippComponents/CippOffboardingDefaultSettings.jsx index 5b6189ad2a7c..34fefd8ab509 100644 --- a/src/components/CippComponents/CippOffboardingDefaultSettings.jsx +++ b/src/components/CippComponents/CippOffboardingDefaultSettings.jsx @@ -1,49 +1,43 @@ import { CippPropertyListCard } from '../../components/CippCards/CippPropertyListCard' import CippFormComponent from '../../components/CippComponents/CippFormComponent' -import { Typography, Box } from '@mui/material' +import { Box, Chip, Typography } from '@mui/material' +import { Grid } from '@mui/system' export const CippOffboardingDefaultSettings = (props) => { const { formControl, defaultsSource = null, title = 'Offboarding Default Settings' } = props - const getSourceIndicator = () => { - // Only show the indicator if defaultsSource is explicitly provided (for wizard, not tenant config) - if (!defaultsSource || defaultsSource === null) return null + const getSourceChip = () => { + // Only show the chip if defaultsSource is explicitly provided (for wizard/preferences, not tenant config) + if (!defaultsSource) return null - let sourceText = '' - let color = 'text.secondary' - - switch (defaultsSource) { - case 'tenant': - sourceText = 'Using Tenant Defaults' - color = 'primary.main' - break - case 'user': - sourceText = 'Using User Defaults' - color = 'info.main' - break - case 'none': - default: - sourceText = 'Using Default Settings' - color = 'text.secondary' - break + const sourceConfig = { + tenant: { label: 'Using Tenant Defaults', color: 'primary' }, + user: { label: 'Using User Defaults', color: 'info' }, + allUsers: { label: 'Using All Users Defaults', color: 'default' }, + none: { label: 'Using Default Settings', color: 'default' }, } - return ( - - - {sourceText} - - - ) + const { label, color } = sourceConfig[defaultsSource] ?? sourceConfig.none + + return } + const sourceChip = getSourceChip() + const cardTitle = sourceChip ? ( + + {title} + {sourceChip} + + ) : ( + title + ) + return ( <> - {getSourceIndicator()} { ), }, ]} + cardButton={ + + + Send results to + + + + + + + + + + + + + + } /> ) diff --git a/src/components/CippComponents/CippPolicyDeployDrawer.jsx b/src/components/CippComponents/CippPolicyDeployDrawer.jsx index ad695d39cace..6c0eab9bc7b3 100644 --- a/src/components/CippComponents/CippPolicyDeployDrawer.jsx +++ b/src/components/CippComponents/CippPolicyDeployDrawer.jsx @@ -17,6 +17,38 @@ const assignmentFilterTypeOptions = [ { label: 'Exclude - Apply policy to devices NOT matching filter', value: 'exclude' }, ] +// Reserved replacement variables handled server-side by Get-CIPPTextReplacement. +// These are populated automatically per tenant, so they must never be prompted for here. +// Stored without the surrounding %% and lowercased for case-insensitive matching, since +// templates may reference them in any casing (e.g. %TenantId%, %tenantid%). +const reservedReplacementVariables = new Set( + [ + 'serial', + 'systemroot', + 'systemdrive', + 'system32', + 'osdrive', + 'temp', + 'tenantid', + 'tenantfilter', + 'initialdomain', + 'tenantname', + 'partnertenantid', + 'samappid', + 'userprofile', + 'username', + 'userdomain', + 'windir', + 'programfiles', + 'programfiles(x86)', + 'programdata', + 'cippuserschema', + 'cippurl', + 'defaultdomain', + 'organizationid', + ].map((variable) => variable.toLowerCase()), +) + export const CippPolicyDeployDrawer = ({ buttonText = 'Deploy Policy', requiredPermissions = [], @@ -259,7 +291,9 @@ export const CippPolicyDeployDrawer = ({ {(() => { const rawJson = jsonWatch ? jsonWatch : '' const placeholderMatches = [...rawJson.matchAll(/%(\w+)%/g)].map((m) => m[1]) - const uniquePlaceholders = Array.from(new Set(placeholderMatches)) + const uniquePlaceholders = Array.from(new Set(placeholderMatches)).filter( + (placeholder) => !reservedReplacementVariables.has(placeholder.toLowerCase()), + ) if (uniquePlaceholders.length === 0 || selectedTenants.length === 0) { return null } diff --git a/src/components/CippComponents/CippPolicyImportDrawer.jsx b/src/components/CippComponents/CippPolicyImportDrawer.jsx index 7c1630ce2eb4..ca9f5cf4bf40 100644 --- a/src/components/CippComponents/CippPolicyImportDrawer.jsx +++ b/src/components/CippComponents/CippPolicyImportDrawer.jsx @@ -47,7 +47,7 @@ export const CippPolicyImportDrawer = ({ const tenantPolicies = ApiGetCall({ url: mode === 'ConditionalAccess' - ? `/api/ListCATemplates?TenantFilter=${tenantFilter?.value || ''}` + ? `/api/ListConditionalAccessPolicies?TenantFilter=${tenantFilter?.value || ''}` : mode === 'Standards' ? `/api/listStandardTemplates?TenantFilter=${tenantFilter?.value || ''}` : `/api/ListIntunePolicy?type=ESP&TenantFilter=${tenantFilter?.value || ''}`, @@ -110,12 +110,14 @@ export const CippPolicyImportDrawer = ({ // For Conditional Access, convert RawJSON to object and send the contents let policyData = policy - // If the policy has RawJSON, parse it and use that as the data - if (policy.RawJSON) { + // If the policy has rawjson, parse it and use that as the data. + // ListConditionalAccessPolicies returns the raw Graph policy as lowercase `rawjson`. + const rawJson = policy.rawjson ?? policy.RawJSON + if (rawJson) { try { - policyData = JSON.parse(policy.RawJSON) + policyData = JSON.parse(rawJson) } catch (e) { - console.error('Failed to parse RawJSON:', e) + console.error('Failed to parse rawjson:', e) policyData = policy } } @@ -187,8 +189,19 @@ export const CippPolicyImportDrawer = ({ }, }) } else { - // For tenant policies, use the policy object directly - setViewingPolicy(policy || {}) + // For tenant policies, show the raw policy JSON when available + // (ConditionalAccess returns the Graph policy as lowercase `rawjson`). + const rawJson = policy?.rawjson ?? policy?.RawJSON + if (mode === 'ConditionalAccess' && rawJson) { + try { + setViewingPolicy(JSON.parse(rawJson)) + } catch (e) { + console.error('Failed to parse rawjson for view:', e) + setViewingPolicy(policy || {}) + } + } else { + setViewingPolicy(policy || {}) + } } setViewDialogOpen(true) } catch (error) { diff --git a/src/components/CippComponents/CippSankey.jsx b/src/components/CippComponents/CippSankey.jsx index eb583b801ac4..f22f091e80cc 100644 --- a/src/components/CippComponents/CippSankey.jsx +++ b/src/components/CippComponents/CippSankey.jsx @@ -39,6 +39,7 @@ export const CippSankey = ({ data, onNodeClick, onLinkClick }) => { margin={{ top: 10, right: 10, bottom: 10, left: 10 }} align="justify" colors={(node) => node.nodeColor} + label={(node) => node.label ?? node.id} nodeOpacity={1} nodeHoverOthersOpacity={0.35} nodeThickness={18} diff --git a/src/components/CippComponents/CippSettingsSideBar.jsx b/src/components/CippComponents/CippSettingsSideBar.jsx index 368ca3f19696..f3b07cf36905 100644 --- a/src/components/CippComponents/CippSettingsSideBar.jsx +++ b/src/components/CippComponents/CippSettingsSideBar.jsx @@ -104,6 +104,8 @@ export const CippSettingsSideBar = (props) => { RemoveMFADevices: formValues.offboardingDefaults?.RemoveMFADevices, RemoveTeamsPhoneDID: formValues.offboardingDefaults?.RemoveTeamsPhoneDID, ClearImmutableId: formValues.offboardingDefaults?.ClearImmutableId, + removeCalendarPermissions: formValues.offboardingDefaults?.removeCalendarPermissions, + DisableOneDriveSharing: formValues.offboardingDefaults?.DisableOneDriveSharing, }, }; diff --git a/src/components/CippComponents/CippSitRulePackDetails.jsx b/src/components/CippComponents/CippSitRulePackDetails.jsx new file mode 100644 index 000000000000..ff7d65f456a0 --- /dev/null +++ b/src/components/CippComponents/CippSitRulePackDetails.jsx @@ -0,0 +1,60 @@ +import { Alert, CircularProgress, Stack, Typography } from '@mui/material' +import { ApiGetCall } from '../../api/ApiCall' +import { CippCodeBlock } from './CippCodeBlock' + +// More-info panel for a live custom Sensitive Information Type: looks up its rule pack by RulePackId and +// shows what it actually detects (parsed configuration + the raw ClassificationRuleCollection XML). +export const CippSitRulePackDetails = ({ row, tenant }) => { + const isCustom = Boolean(row?.Publisher) && !String(row.Publisher).startsWith('Microsoft') + // Only classic regex/keyword (Entity) SITs have an inspectable rule configuration. + const isEntity = row?.Type === 'Entity' + const shouldShow = isCustom && isEntity + const tenantFilter = tenant === 'AllTenants' && row?.Tenant ? row.Tenant : tenant + + const rulePack = ApiGetCall({ + url: '/api/ListSensitiveInfoTypeRulePackage', + queryKey: `SitRulePack-${tenantFilter}-${row?.RulePackId}`, + data: { tenantFilter, RulePackId: row?.RulePackId }, + waiting: Boolean(shouldShow && tenantFilter && row?.RulePackId), + retry: 1, + refetchOnWindowFocus: false, + toast: false, + }) + + if (!shouldShow) { + return null + } + + if (rulePack.isLoading || rulePack.isFetching) { + return ( + + + + Looking up rule pack {row?.RulePackId}... + + + ) + } + + if (rulePack.isError || !rulePack.data?.Xml) { + return ( + + Could not load the rule pack configuration for this Sensitive Information Type. + + ) + } + + const data = rulePack.data + return ( + + Detection configuration + + Rule pack XML ({data.RulePackId}) + + + ) +} diff --git a/src/components/CippComponents/CippSitTemplateDetails.jsx b/src/components/CippComponents/CippSitTemplateDetails.jsx new file mode 100644 index 000000000000..001cae8d0059 --- /dev/null +++ b/src/components/CippComponents/CippSitTemplateDetails.jsx @@ -0,0 +1,140 @@ +import { Alert, Stack, Typography } from '@mui/material' +import { CippCodeBlock } from './CippCodeBlock' + +// Decode the stored FileDataBase64 (UTF-16LE rule pack bytes) back into XML for exploring. +const decodeFileData = (b64) => { + try { + const bin = atob(b64) + const bytes = new Uint8Array(bin.length) + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i) + let xml = new TextDecoder('utf-16le').decode(bytes) + if (!xml.includes(' { confidence, proximity, description, patterns:[{ level, matches:[regex:.. / keyword:.. ] }] }. +const parseSitConfig = (xml) => { + try { + const doc = new DOMParser().parseFromString(xml, 'application/xml') + if (doc.getElementsByTagName('parsererror').length) return null + const all = Array.from(doc.getElementsByTagName('*')) + const byLocal = (name) => all.filter((n) => n.localName === name) + + const regexMap = {} + byLocal('Regex').forEach((n) => { + if (n.getAttribute('id')) regexMap[n.getAttribute('id')] = (n.textContent || '').trim() + }) + const keywordMap = {} + byLocal('Keyword').forEach((n) => { + if (!n.getAttribute('id')) return + const terms = Array.from(n.getElementsByTagName('*')) + .filter((t) => t.localName === 'Term') + .map((t) => (t.textContent || '').trim()) + .sort() + keywordMap[n.getAttribute('id')] = terms.join('|') + }) + const resMap = {} + byLocal('Resource').forEach((res) => { + const idRef = res.getAttribute('idRef') + if (!idRef) return + const kids = Array.from(res.children) + resMap[idRef] = { + name: kids.find((c) => c.localName === 'Name')?.textContent?.trim() || '', + description: kids.find((c) => c.localName === 'Description')?.textContent?.trim() || '', + } + }) + + const config = {} + all + .filter((n) => n.localName === 'Entity' || n.localName === 'Affinity') + .forEach((ent) => { + const eid = ent.getAttribute('id') + const name = resMap[eid]?.name || eid + const patterns = Array.from(ent.getElementsByTagName('*')) + .filter((p) => p.localName === 'Pattern' || p.localName === 'Evidence') + .map((p) => { + const matches = Array.from(p.getElementsByTagName('*')) + .filter((m) => m.getAttribute('idRef')) + .map((m) => { + const ref = m.getAttribute('idRef') + if (regexMap[ref] !== undefined) return `regex:${regexMap[ref]}` + if (keywordMap[ref] !== undefined) return `keyword:${keywordMap[ref]}` + return `fingerprint:${ref}` + }) + .sort() + return { level: p.getAttribute('confidenceLevel') || '', matches } + }) + config[name] = { + confidence: + ent.getAttribute('recommendedConfidence') || ent.getAttribute('thresholdConfidenceLevel') || '', + proximity: ent.getAttribute('patternsProximity') || ent.getAttribute('evidencesProximity') || '', + description: resMap[eid]?.description || '', + patterns, + } + }) + return config + } catch { + return null + } +} + +// More-info panel for a Sensitive Information Type template: explore the captured rule pack data. +export const CippSitTemplateDetails = ({ row }) => { + const isAdvanced = Boolean(row?.FileDataBase64) + const xml = isAdvanced ? decodeFileData(row.FileDataBase64) : null + const config = xml ? parseSitConfig(xml) : null + + return ( + + + {isAdvanced + ? 'Advanced template — the captured rule pack is stored as base64. The decoded detection config and XML below are exactly what gets deployed.' + : 'Simple template — the backend synthesizes a rule pack from this pattern at deploy time.'} + + + {!isAdvanced && row?.Pattern && ( + + )} + + {isAdvanced && config && Object.keys(config).length > 0 && ( + <> + Detection configuration + + + )} + + {isAdvanced && xml && ( + <> + Rule pack XML (decoded from base64) + + + )} + + {isAdvanced && !xml && ( + + Could not decode the stored rule pack data. + + )} + + ) +} diff --git a/src/components/CippComponents/CippTenantGroupOffCanvas.jsx b/src/components/CippComponents/CippTenantGroupOffCanvas.jsx index 05ed8e18f836..490818307141 100644 --- a/src/components/CippComponents/CippTenantGroupOffCanvas.jsx +++ b/src/components/CippComponents/CippTenantGroupOffCanvas.jsx @@ -46,6 +46,9 @@ export const CippTenantGroupOffCanvas = ({ data }) => { ne: "not equals", in: "in", notIn: "not in", + notin: "not in", + like: "contains", + notlike: "does not contain", contains: "contains", startsWith: "starts with", endsWith: "ends with", @@ -54,6 +57,54 @@ export const CippTenantGroupOffCanvas = ({ data }) => { // Handle both single rule object and array of rules const rules = Array.isArray(data.DynamicRules) ? data.DynamicRules : [data.DynamicRules]; + // Resolve a value that may be a string, a {label, value} option, or a raw object + const resolveOptionLabel = (item) => { + if (item === null || item === undefined) return ""; + if (typeof item === "object") return item.label ?? item.value ?? JSON.stringify(item); + return item; + }; + + const renderRuleValue = (rule) => { + // Custom Variable rules store a nested { variableName, value } object + if (rule.property === "customVariable" || rule.value?.variableName !== undefined) { + const variableName = resolveOptionLabel(rule.value?.variableName); + const expectedValue = resolveOptionLabel(rule.value?.value); + return ( + + ); + } + + if (Array.isArray(rule.value)) { + return ( + + {rule.value.map((item, valueIndex) => ( + + ))} + + ); + } + + return ( + + ); + }; + const renderRule = (rule, index) => ( { Value(s): - {Array.isArray(rule.value) ? ( - - {rule.value.map((item, valueIndex) => ( - - ))} - - ) : ( - - )} + {renderRuleValue(rule)} ); diff --git a/src/components/CippComponents/CippTenantGroupRuleBuilder.jsx b/src/components/CippComponents/CippTenantGroupRuleBuilder.jsx index e61aaf768be5..3133124dfe0b 100644 --- a/src/components/CippComponents/CippTenantGroupRuleBuilder.jsx +++ b/src/components/CippComponents/CippTenantGroupRuleBuilder.jsx @@ -40,9 +40,11 @@ const CippTenantGroupRuleBuilder = ({ formControl, name = "dynamicRules" }) => { // Flatten all pages and extract Results const allGroups = tenantGroupsQuery.data.pages.flatMap((page) => page?.Results || []); return allGroups - .filter((group) => group.GroupType === "static") .map((group) => ({ - label: group.Name || group.displayName, + label: + group.GroupType === "dynamic" + ? `${group.Name || group.displayName} (dynamic)` + : group.Name || group.displayName, value: group.Id || group.RowKey, type: group.GroupType, })) diff --git a/src/components/CippComponents/CippTimeAgo.jsx b/src/components/CippComponents/CippTimeAgo.jsx index a97d71e03acc..9573b4cfa936 100644 --- a/src/components/CippComponents/CippTimeAgo.jsx +++ b/src/components/CippComponents/CippTimeAgo.jsx @@ -1,11 +1,10 @@ import { Chip } from "@mui/material"; import ReactTimeAgo from "react-time-ago"; +import { parseCippDate } from "../../utils/parse-cipp-date"; export const CippTimeAgo = ({ data, type = "text", timeStyle = "round-minute" }) => { const isText = type === "text"; - const numberRegex = /^\d+$/; - const date = - typeof data === "number" || numberRegex.test(data) ? new Date(data * 1000) : new Date(data); + const date = parseCippDate(data); if (date.getTime() === 0) { return "Never"; diff --git a/src/components/CippComponents/CippUserActions.jsx b/src/components/CippComponents/CippUserActions.jsx index 2ac784716799..deaa03a68fac 100644 --- a/src/components/CippComponents/CippUserActions.jsx +++ b/src/components/CippComponents/CippUserActions.jsx @@ -451,6 +451,7 @@ export const useCippUserActions = () => { confirmText: 'Are you sure you want to create a Temporary Access Pass for [userPrincipalName]?', multiPost: false, + allowResubmit: true, condition: () => canWriteUser, }, { diff --git a/src/components/CippComponents/EnterpriseAppActions.jsx b/src/components/CippComponents/EnterpriseAppActions.jsx index c55f87d8d240..1d97f6910211 100644 --- a/src/components/CippComponents/EnterpriseAppActions.jsx +++ b/src/components/CippComponents/EnterpriseAppActions.jsx @@ -44,7 +44,7 @@ export const getEnterpriseAppPostActions = (canWriteApplication) => [ }, ], confirmText: - "Create a deployment template from '[displayName]'? This will copy all permissions and create a reusable template.", + "'[displayName]' is a multi-tenant app, so a multi-tenant Enterprise App template will be created. This copies all permissions into a reusable template.", condition: (row) => canWriteApplication && row?.signInAudience === 'AzureADMultipleOrgs', }, { diff --git a/src/components/CippComponents/LicenseCard.jsx b/src/components/CippComponents/LicenseCard.jsx index dce02b1e12f6..5e59011903b3 100644 --- a/src/components/CippComponents/LicenseCard.jsx +++ b/src/components/CippComponents/LicenseCard.jsx @@ -1,8 +1,10 @@ import { Box, Card, CardHeader, CardContent, Typography, Divider, Skeleton } from "@mui/material"; import { CardMembership as CardMembershipIcon } from "@mui/icons-material"; import { CippSankey } from "./CippSankey"; +import { useRouter } from "next/router"; export const LicenseCard = ({ data, isLoading }) => { + const router = useRouter(); const processData = () => { if (!data || !Array.isArray(data) || data.length === 0) { return null; @@ -19,6 +21,7 @@ export const LicenseCard = ({ data, isLoading }) => { const nodes = []; const links = []; + const licenseLookup = {}; topLicenses.forEach((license, index) => { if (license) { @@ -30,22 +33,33 @@ export const LicenseCard = ({ data, isLoading }) => { const assigned = parseInt(license?.CountUsed || 0) || 0; const available = parseInt(license?.CountAvailable || 0) || 0; + // Use the index to keep node ids unique even when two licenses truncate + // to the same shortName; the visible label stays the truncated name. + const nodeId = `${index}-${shortName}`; + const assignedId = `${nodeId} - Assigned`; + const availableId = `${nodeId} - Available`; + nodes.push({ - id: shortName, + id: nodeId, + label: shortName, nodeColor: `hsl(${210 + index * 30}, 70%, 50%)`, }); - const assignedId = `${shortName} - Assigned`; - const availableId = `${shortName} - Available`; + // Map every node id back to the full license name so a click can filter + // the report on the real License value. + licenseLookup[nodeId] = licenseName; + licenseLookup[assignedId] = licenseName; + licenseLookup[availableId] = licenseName; if (assigned > 0) { nodes.push({ id: assignedId, + label: `${shortName} - Assigned`, nodeColor: "hsl(99, 70%, 50%)", }); links.push({ - source: shortName, + source: nodeId, target: assignedId, value: assigned, }); @@ -54,11 +68,12 @@ export const LicenseCard = ({ data, isLoading }) => { if (available > 0) { nodes.push({ id: availableId, + label: `${shortName} - Available`, nodeColor: "hsl(28, 100%, 53%)", }); links.push({ - source: shortName, + source: nodeId, target: availableId, value: available, }); @@ -70,11 +85,30 @@ export const LicenseCard = ({ data, isLoading }) => { return null; } - return { nodes, links }; + return { nodes, links, licenseLookup }; }; const processedData = processData(); + const navigateToLicense = (nodeId) => { + const fullName = processedData?.licenseLookup?.[nodeId]; + if (!fullName) { + return; + } + router.push({ + pathname: "/tenant/reports/list-licenses", + query: { filters: JSON.stringify([{ id: "License", value: fullName }]) }, + }); + }; + + const handleNodeClick = (node) => { + navigateToLicense(node?.id); + }; + + const handleLinkClick = (link) => { + navigateToLicense(link?.source?.id ?? link?.source); + }; + const calculateStats = () => { if (!data || !Array.isArray(data)) { return { total: 0, assigned: 0, available: 0 }; @@ -93,7 +127,17 @@ export const LicenseCard = ({ data, isLoading }) => { + router.push("/tenant/reports/list-licenses")} + sx={{ + display: "flex", + alignItems: "center", + gap: 1, + cursor: "pointer", + width: "fit-content", + "&:hover": { textDecoration: "underline" }, + }} + > License Overview @@ -105,7 +149,11 @@ export const LicenseCard = ({ data, isLoading }) => { {isLoading ? ( ) : processedData ? ( - + ) : ( { + router.push("/identity/reports/mfa-report")} + sx={{ + display: "flex", + alignItems: "center", + gap: 1, + cursor: "pointer", + width: "fit-content", + "&:hover": { textDecoration: "underline" }, + }} + > User authentication diff --git a/src/components/CippComponents/SecureScoreCard.jsx b/src/components/CippComponents/SecureScoreCard.jsx index 51940abfc2d9..a1732a6d41d5 100644 --- a/src/components/CippComponents/SecureScoreCard.jsx +++ b/src/components/CippComponents/SecureScoreCard.jsx @@ -1,5 +1,6 @@ import { Box, Card, CardHeader, CardContent, Typography, Divider, Skeleton } from '@mui/material' import { Security as SecurityIcon } from '@mui/icons-material' +import { useRouter } from 'next/router' import { LineChart, Line, @@ -12,11 +13,22 @@ import { } from 'recharts' export const SecureScoreCard = ({ data, isLoading }) => { + const router = useRouter() return ( + router.push('/tenant/administration/securescore')} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 1, + cursor: 'pointer', + width: 'fit-content', + '&:hover': { textDecoration: 'underline' }, + }} + > Secure Score diff --git a/src/components/CippComponents/TenantMetricsGrid.jsx b/src/components/CippComponents/TenantMetricsGrid.jsx index 323bd44a7f9f..35eda0143286 100644 --- a/src/components/CippComponents/TenantMetricsGrid.jsx +++ b/src/components/CippComponents/TenantMetricsGrid.jsx @@ -84,12 +84,13 @@ export const TenantMetricsGrid = ({ data, isLoading }) => { sx={{ display: "flex", alignItems: "center", - gap: 1.5, - p: 2, + gap: { xs: 1, sm: 1.5 }, + p: { xs: 1, sm: 1.5, md: 2 }, border: 1, borderColor: "divider", borderRadius: 1, cursor: "pointer", + minWidth: 0, transition: "all 0.2s ease-in-out", "&:hover": { borderColor: `${metric.color}.main`, @@ -103,18 +104,24 @@ export const TenantMetricsGrid = ({ data, isLoading }) => { sx={{ bgcolor: `${metric.color}.main`, color: `${metric.color}.contrastText`, - width: 34, - height: 34, + width: { xs: 28, sm: 32, md: 34 }, + height: { xs: 28, sm: 32, md: 34 }, + flexShrink: 0, }} > - + - - + + {metric.label} - - {isLoading ? : formatNumber(metric.value)} + + {isLoading ? : formatNumber(metric.value)} diff --git a/src/components/CippFormPages/CippAddEditUser.jsx b/src/components/CippFormPages/CippAddEditUser.jsx index 9a3559190372..e4cb3af89a8a 100644 --- a/src/components/CippFormPages/CippAddEditUser.jsx +++ b/src/components/CippFormPages/CippAddEditUser.jsx @@ -50,7 +50,7 @@ const CippAddEditUser = (props) => { // Get all groups for the tenant const tenantGroups = ApiGetCall({ url: `/api/ListGroups?tenantFilter=${tenantDomain}`, - queryKey: `ListGroups-${tenantDomain}`, + queryKey: `TenantGroupsList-${tenantDomain}`, refetchOnMount: false, refetchOnReconnect: false, }) @@ -318,7 +318,12 @@ const CippAddEditUser = (props) => { setFieldIfEmpty('companyName', template.companyName) setFieldIfEmpty('department', template.department) setFieldIfEmpty('mobilePhone', template.mobilePhone) - setFieldIfEmpty('businessPhones[0]', template.businessPhones) + const templateBusinessPhone = Array.isArray(template.businessPhones) + ? template.businessPhones[0] + : template.businessPhones + if (templateBusinessPhone) { + formControl.setValue('businessPhones', [templateBusinessPhone]) + } // Handle licenses - need to match the format expected by CippFormLicenseSelector if (template.licenses && Array.isArray(template.licenses)) { @@ -825,6 +830,7 @@ const CippAddEditUser = (props) => { value: group.id, addedFields: { groupType: group.groupType, + calculatedGroupType: group.calculatedGroupType, }, })) || [] } @@ -914,65 +920,59 @@ const CippAddEditUser = (props) => { })} )} - {/* Schedule User Creation */} - {formType === 'add' && ( - <> - - - - - - - - - - - - - - - - - - - - )} + {/* Schedule User Creation / Edit */} + <> + + + + + + + + + + + + + + + + + + + ) } diff --git a/src/components/CippIntegrations/CippIntegrationFieldMapping.jsx b/src/components/CippIntegrations/CippIntegrationFieldMapping.jsx index 53a88787de19..76162fef0daa 100644 --- a/src/components/CippIntegrations/CippIntegrationFieldMapping.jsx +++ b/src/components/CippIntegrations/CippIntegrationFieldMapping.jsx @@ -42,7 +42,7 @@ const CippIntegrationFieldMapping = () => { var missingMappings = []; fieldMapping?.data?.Mappings?.forEach((mapping) => { const exists = fieldMapping?.data?.IntegrationFields?.some( - (integrationField) => String(integrationField.value) === mapping.IntegrationId + (integrationField) => String(integrationField?.value) === mapping.IntegrationId ); if (exists) { newMappings[mapping.RowKey] = { diff --git a/src/components/CippIntegrations/CippIntegrationSettings.jsx b/src/components/CippIntegrations/CippIntegrationSettings.jsx index d0156df3897a..e5a8cb67cfe4 100644 --- a/src/components/CippIntegrations/CippIntegrationSettings.jsx +++ b/src/components/CippIntegrations/CippIntegrationSettings.jsx @@ -62,7 +62,7 @@ const CippIntegrationSettings = ({ children }) => { {setting?.condition ? ( s.name === `${extension.id}.Enabled`) && !enabled}> - + { ) : ( - + { const { executeCheck, offcanvasVisible, setOffcanvasVisible, importReport, setCardIcon } = props; const [results, setResults] = useState({}); + const repairRoleMappings = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: ["ExecAccessChecks-GDAP"], + }); + + const handleRepairRoleMappings = () => { + repairRoleMappings.mutate({ + url: "/api/ExecGDAPRepairRoleMappings", + data: {}, + queryKey: "RepairGDAPRoleMappings", + }); + }; + + const hasRoleMappingIssues = results?.Results?.RoleMappingResults?.some( + (item) => item?.Status === "Stale" || item?.Status === "Missing", + ); + useEffect(() => { if (importReport) { setResults(importReport); @@ -19,7 +38,11 @@ export const CippGDAPResults = (props) => { }, [executeCheck, importReport]); useEffect(() => { - if (results?.Results?.GDAPIssues?.length > 0 || results?.Results?.MissingGroups?.length > 0) { + if ( + results?.Results?.GDAPIssues?.length > 0 || + results?.Results?.MissingGroups?.length > 0 || + hasRoleMappingIssues + ) { setCardIcon(); } else { setCardIcon(); @@ -77,6 +100,15 @@ export const CippGDAPResults = (props) => { successMessage: "No Global Admin relationships found", failureMessage: "Global Admin relationships found", }, + { + resultProperty: "RoleMappingResults", + matchProperty: "Status", + match: "^(Stale|Missing)$", + count: 0, + successMessage: "All GDAP role mappings reference existing security groups", + failureMessage: + "One or more GDAP role mappings reference stale or missing security groups. Click Details to repair.", + }, ]; const propertyItems = [ @@ -154,13 +186,16 @@ export const CippGDAPResults = (props) => { }} extendedInfo={[]} > - {results?.Results?.GDAPIssues?.length > 0 && ( + {results?.Results?.GDAPIssues?.filter((issue) => issue.Category !== "RoleMapping") + .length > 0 && ( <> issue.Category !== "RoleMapping", + )} simpleColumns={["Tenant", "Type", "Issue", "Link"]} /> @@ -178,6 +213,37 @@ export const CippGDAPResults = (props) => { )} + {results?.Results?.RoleMappingResults?.length > 0 && ( + <> + + + + + } + > + Repair Role Mappings + + ) + } + data={results?.Results?.RoleMappingResults} + simpleColumns={["RoleName", "GroupName", "GroupId", "Status", "Message"]} + /> + + )} + {results?.Results?.Memberships?.filter( (membership) => membership?.["@odata.type"] === "#microsoft.graph.group", ).length > 0 && ( diff --git a/src/components/CippTable/CippDataTable.js b/src/components/CippTable/CippDataTable.js index 284579c2b60d..f140fab281af 100644 --- a/src/components/CippTable/CippDataTable.js +++ b/src/components/CippTable/CippDataTable.js @@ -24,6 +24,7 @@ import { CippApiDialog } from '../CippComponents/CippApiDialog' import { getCippError } from '../../utils/get-cipp-error' import { Box } from '@mui/system' import { useSettings } from '../../hooks/use-settings' +import { parseCippDate } from '../../utils/parse-cipp-date' import { isEqual } from 'lodash' // Import lodash for deep comparison import { useLicenseBackfill } from '../../hooks/use-license-backfill' @@ -80,12 +81,16 @@ const compareNullable = (aVal, bVal) => { // These never change between renders, so extracting them avoids creating new // object references on every render cycle. +// Stable ref so an undefined `data` prop doesn't create a fresh [] each render +// and loop the static-data sync effect. +const EMPTY_ARRAY = [] + const SORTING_FNS = { dateTimeNullsLast: (a, b, id) => { const aRaw = getRowValueByColumnId(a, id) const bRaw = getRowValueByColumnId(b, id) - const aDate = aRaw ? new Date(aRaw) : null - const bDate = bRaw ? new Date(bRaw) : null + const aDate = aRaw ? parseCippDate(aRaw) : null + const bDate = bRaw ? parseCippDate(bRaw) : null const aTime = aDate && !Number.isNaN(aDate.getTime()) ? aDate.getTime() : null const bTime = bDate && !Number.isNaN(bDate.getTime()) ? bDate.getTime() : null @@ -327,7 +332,7 @@ function renderColumnFilterModeMenuItemsFn({ internalFilterOptions, onSelectFilt export const CippDataTable = (props) => { const { queryKey, - data = [], + data = EMPTY_ARRAY, columns = [], api = {}, isFetching = false, @@ -729,13 +734,16 @@ export const CippDataTable = (props) => { sx={{ color: action.color }} key={`actions-list-row-${index}`} onClick={() => { - if (settings.currentTenant === 'AllTenants' && row.original?.Tenant) { - settings.handleUpdate({ - currentTenant: row.original.Tenant, - }) + const scopeToRowTenant = () => { + if (settings.currentTenant === 'AllTenants' && row.original?.Tenant) { + settings.handleUpdate({ + currentTenant: row.original.Tenant, + }) + } } if (action.noConfirm && action.customFunction) { + scopeToRowTenant() action.customFunction(row.original, action, {}) closeMenu() return @@ -743,6 +751,7 @@ export const CippDataTable = (props) => { // Handle custom component differently if (typeof action.customComponent === 'function') { + scopeToRowTenant() setCustomComponentData({ data: row.original, action: action }) setCustomComponentVisible(true) closeMenu() diff --git a/src/components/CippTable/util-columnsFromAPI.js b/src/components/CippTable/util-columnsFromAPI.js index 65fdbb411f19..ad170fc3ddff 100644 --- a/src/components/CippTable/util-columnsFromAPI.js +++ b/src/components/CippTable/util-columnsFromAPI.js @@ -2,8 +2,7 @@ import { getCippFilterVariant } from '../../utils/get-cipp-filter-variant' import { getCippFormatting } from '../../utils/get-cipp-formatting' import { getCippTranslation } from '../../utils/get-cipp-translation' import { getCippColumnSize } from '../../utils/get-cipp-column-size' - -const skipRecursion = ['location', 'ScheduledBackupValues', 'Tenant'] +import { SKIP_RECURSION_KEYS as skipRecursion } from '../../utils/skip-recursion-keys' // Number of rows to sample when measuring column content width. const MAX_SIZE_SAMPLE = 30 @@ -36,7 +35,12 @@ const TIME_AGO_NAMES = new Set([ 'requestDate', 'reviewedDate', 'GeneratedAt', ]) const MATCH_DATE_TIME = /([dD]ate[tT]ime|[Ee]xpiration|[Tt]imestamp|[sS]tart[Dd]ate)/ -const isDateTimeColumn = (key) => TIME_AGO_NAMES.has(key) || MATCH_DATE_TIME.test(key) +const ABSOLUTE_DATE_NAMES = new Set([ + 'WindowStart', 'WindowEnd', 'CreatedUtc', 'DownloadedUtc', 'ProcessedUtc', + 'NextAttemptUtc', 'LastErrorUtc', 'LastPolledUtc', +]) +const isDateTimeColumn = (key) => + TIME_AGO_NAMES.has(key) || ABSOLUTE_DATE_NAMES.has(key) || MATCH_DATE_TIME.test(key) // Measure the pixel width a column needs based on its header and sampled cell values. // rawValues are the original data values (before formatting) — if they contain arrays or diff --git a/src/components/CippWizard/CippWizardOffboarding.jsx b/src/components/CippWizard/CippWizardOffboarding.jsx index 990cb9d35b11..2fc5947c22bf 100644 --- a/src/components/CippWizard/CippWizardOffboarding.jsx +++ b/src/components/CippWizard/CippWizardOffboarding.jsx @@ -12,9 +12,13 @@ import CippWizardStepButtons from './CippWizardStepButtons' import CippFormComponent from '../CippComponents/CippFormComponent' import { CippFormCondition } from '../CippComponents/CippFormCondition' import { useWatch } from 'react-hook-form' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Grid } from '@mui/system' import { useSettings } from '../../hooks/use-settings' +import { ApiGetCall } from '../../api/ApiCall' + +// Shared mailboxes are capped at 50 GiB without a license; warn at 49 GiB. +const SHARED_MAILBOX_WARN_BYTES = 49 * 1024 ** 3 export const CippWizardOffboarding = (props) => { const { postUrl, formControl, onPreviousStep, onNextStep, currentStep } = props @@ -24,6 +28,40 @@ export const CippWizardOffboarding = (props) => { const userSettingsDefaults = useSettings().userSettingsDefaults const disableForwarding = useWatch({ control: formControl.control, name: 'disableForwarding' }) const deleteUser = useWatch({ control: formControl.control, name: 'DeleteUser' }) + const convertToShared = useWatch({ control: formControl.control, name: 'ConvertToShared' }) + + // Pull cached mailbox sizes (storageUsedInBytes, keyed by UPN) only when relevant + const mailboxUsage = ApiGetCall({ + url: '/api/ListMailboxes', + data: { tenantFilter: currentTenant?.value, UseReportDB: true }, + queryKey: `OffboardingMailboxUsage-${currentTenant?.value}`, + waiting: !!convertToShared && !!currentTenant?.value && selectedUsers?.length > 0, + }) + + // Selected mailboxes whose cached size would exceed the shared-mailbox limit + const oversizedMailboxes = useMemo(() => { + if (!convertToShared || !mailboxUsage.isSuccess || !Array.isArray(mailboxUsage.data)) { + return [] + } + const selectedUpns = (selectedUsers || []).map((u) => + (u?.value ?? u)?.toString().toLowerCase(), + ) + return mailboxUsage.data + .filter((mb) => { + const upn = mb?.UPN?.toString().toLowerCase() + const bytes = Number(mb?.storageUsedInBytes) + return ( + upn && + selectedUpns.includes(upn) && + Number.isFinite(bytes) && + bytes >= SHARED_MAILBOX_WARN_BYTES + ) + }) + .map((mb) => ({ + upn: mb.UPN, + sizeGB: (Number(mb.storageUsedInBytes) / 1024 ** 3).toFixed(1), + })) + }, [convertToShared, mailboxUsage.isSuccess, mailboxUsage.data, selectedUsers]) useEffect(() => { if (selectedUsers.length >= 3) { @@ -383,6 +421,21 @@ export const CippWizardOffboarding = (props) => { formControl={formControl} /> + {convertToShared && oversizedMailboxes.length > 0 && ( + + The following mailbox{oversizedMailboxes.length > 1 ? 'es' : ''} exceed or are near + the 50 GB shared mailbox limit. Converting to shared may fail, or the mailbox may + stop receiving mail once unlicensed, unless an Exchange Online Plan 2 license is + retained: + + {oversizedMailboxes.map((mb) => ( +
  • + {mb.upn} ({mb.sizeGB} GB) +
  • + ))} +
    +
    + )}
    diff --git a/src/components/PrivateRoute.js b/src/components/PrivateRoute.js index 15b438b2c608..821233c1eca8 100644 --- a/src/components/PrivateRoute.js +++ b/src/components/PrivateRoute.js @@ -4,6 +4,13 @@ import LoadingPage from "../pages/loading.js"; import ApiOfflinePage from "../pages/api-offline.js"; import { useState, useEffect } from "react"; +// EasyAuth exposes the signed-in identity in two shapes depending on the host: +// - Static Web Apps: { clientPrincipal: { userDetails, userRoles, ... } } +// - App Service EasyAuth: [ { user_id, user_claims: [...], access_token, ... } ] +// an authenticated session must be detected from either populated shape. +const hasAuthenticatedSession = (data) => + Boolean(data?.clientPrincipal) || (Array.isArray(data) && data.length > 0); + export const PrivateRoute = ({ children, routeType }) => { const [unauthLatched, setUnauthLatched] = useState(false); @@ -14,27 +21,26 @@ export const PrivateRoute = ({ children, routeType }) => { staleTime: 120000, // 2 minutes }); - // Latch the unauthenticated state so refetches from child components - // don't flip us back to loading. Clear the latch when session succeeds (after login). + // Latch the unauthenticated state so refetches from child components don't flip us + // back to loading. Latch on a request error or a settled session with no identity; + // clear it as soon as an authenticated session (either shape) is seen. useEffect(() => { if ( !session.isLoading && !session.isFetching && - (session.isError || - null === session?.data?.clientPrincipal || - session?.data === undefined) + (session.isError || !hasAuthenticatedSession(session.data)) ) { setUnauthLatched(true); - } else if (session.isSuccess && session.data?.clientPrincipal) { + } else if (hasAuthenticatedSession(session.data)) { setUnauthLatched(false); } - }, [session.isLoading, session.isFetching, session.isError, session.isSuccess, session.data]); + }, [session.isLoading, session.isFetching, session.isError, session.data]); const apiRoles = ApiGetCall({ url: "/api/me", queryKey: "authmecipp", retry: 2, - waiting: session.isSuccess && session.data?.clientPrincipal !== null, + waiting: session.isSuccess && hasAuthenticatedSession(session.data), }); // If latched as unauthenticated, always show unauthenticated page diff --git a/src/components/ReleaseNotesDialog.js b/src/components/ReleaseNotesDialog.js index 5b99535d1c36..6bc2c53cabb6 100644 --- a/src/components/ReleaseNotesDialog.js +++ b/src/components/ReleaseNotesDialog.js @@ -168,11 +168,7 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { const releaseListQuery = ApiGetCall({ url: '/api/ListGitHubReleaseNotes', - queryKey: 'list-github-release-options', - data: { - Owner: RELEASE_OWNER, - Repository: RELEASE_REPO, - }, + queryKey: `list-github-release-options`, waiting: shouldFetchReleaseList, staleTime: 300000, }) @@ -484,7 +480,13 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { > View release notes on GitHub - + + + {execSamAppPermissions.isLoading && } {execSamAppPermissions.isSuccess && ( { )} + setResetDialogOpen(false)} + title="Reset to CIPP Defaults" + variant="warning" + message="This removes all additional permissions you have layered on top of the CIPP-SAM defaults and returns the saved permission set to the built-in CIPP manifest defaults. The default permissions themselves are unaffected. You will need to complete a Permissions repair from the Permissions page, then complete a CPV refresh to finalise the chnages. Continue?" + onConfirm={() => { + handleResetToCippDefaults(); + setResetDialogOpen(false); + }} + /> ); }; diff --git a/src/pages/cipp/integrations/configure.js b/src/pages/cipp/integrations/configure.js index 578317466c54..38f613b61e21 100644 --- a/src/pages/cipp/integrations/configure.js +++ b/src/pages/cipp/integrations/configure.js @@ -2,6 +2,7 @@ import { Alert, Box, Button, + Card, CardContent, Skeleton, Stack, diff --git a/src/pages/cipp/preferences.js b/src/pages/cipp/preferences.js index 2b5303fa8225..1f390753c2a8 100644 --- a/src/pages/cipp/preferences.js +++ b/src/pages/cipp/preferences.js @@ -363,7 +363,10 @@ const Page = () => { }, ]} /> - +
    diff --git a/src/pages/cipp/settings/backup.js b/src/pages/cipp/settings/backup.js index dc1786a10b5e..dab03a2aacf7 100644 --- a/src/pages/cipp/settings/backup.js +++ b/src/pages/cipp/settings/backup.js @@ -2,8 +2,14 @@ import { Alert, Box, Button, + Card, CardContent, + CardHeader, + Divider, + FormControlLabel, Stack, + Switch, + TextField, Typography, Skeleton, Input, @@ -13,6 +19,7 @@ import { import { Layout as DashboardLayout } from "../../../layouts/index.js"; import CippPageCard from "../../../components/CippCards/CippPageCard"; import { ApiGetCall, ApiPostCall } from "../../../api/ApiCall"; +import { CippApiResults } from "../../../components/CippComponents/CippApiResults"; import { CippInfoBar } from "../../../components/CippCards/CippInfoBar"; import { ArrowCircleRight, @@ -30,10 +37,87 @@ import { CippDataTable } from "../../../components/CippTable/CippDataTable"; import { CippApiDialog } from "../../../components/CippComponents/CippApiDialog"; import { CippRestoreWizard } from "../../../components/CippComponents/CippRestoreWizard"; import { BackupValidator } from "../../../utils/backupValidation"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useDialog } from "../../../hooks/use-dialog"; +const ReplicationScopeCard = ({ scope, label, description, config }) => { + const save = ApiPostCall({ relatedQueryKeys: "BackupReplicationConfig" }); + const [sasUrl, setSasUrl] = useState(""); + const [enabled, setEnabled] = useState(false); + + useEffect(() => { + setEnabled(Boolean(config?.Enabled)); + setSasUrl(""); + }, [config?.Enabled, config?.IsSet]); + + const isSet = Boolean(config?.IsSet); + + const handleSave = () => { + const data = { BackupType: scope, Enabled: enabled }; + if (sasUrl) { + data.SASUrl = sasUrl; + } else if (isSet) { + // Keep the existing stored SAS URL untouched. + data.SASUrl = "SentToKeyVault"; + } + save.mutate({ url: "/api/ExecBackupReplicationConfig", data }); + }; + + return ( + + + + + {label} + + {description} + + + setEnabled(e.target.checked)} + /> + } + label={Enable replication} + /> + setSasUrl(e.target.value)} + size="small" + fullWidth + type="password" + placeholder={ + isSet ? "•••••••••• (stored — leave blank to keep)" : "https://account.blob.core.windows.net/container?sv=..." + } + helperText="Container-level SAS URL with write and create permissions." + /> + + + + + + + + ); +}; + const Page = () => { + const replicationConfig = ApiGetCall({ + url: "/api/ExecBackupReplicationConfig", + data: { List: true }, + queryKey: "BackupReplicationConfig", + }); const [validationResult, setValidationResult] = useState(null); const wizardDialog = useDialog(); const runBackupDialog = useDialog(); @@ -317,6 +401,43 @@ const Page = () => { + + + + + + + + When enabled, each new backup is also uploaded to the external container described by the + SAS URL. The SAS URL is stored securely in Key Vault. This does not copy existing backups. + This will continue to push backups to the container without any consideration for storage costs, so please monitor your external storage usage. + + + + + + + + + + + + + + { + {/* Alerts Section - Full Width */} + + + + {/* Identity Section - 2 Column Grid */} diff --git a/src/pages/email/administration/contacts-template/add.jsx b/src/pages/email/administration/contacts-template/add.jsx index b05da569e29e..f43bf99d7721 100644 --- a/src/pages/email/administration/contacts-template/add.jsx +++ b/src/pages/email/administration/contacts-template/add.jsx @@ -37,27 +37,27 @@ const AddContactTemplates = () => { resetForm={true} customDataformatter={(values) => { return { - DisplayName: values.displayName, + displayName: values.displayName, hidefromGAL: values.hidefromGAL, email: values.email, - FirstName: values.firstName, - LastName: values.lastName, - Title: values.jobTitle, - StreetAddress: values.streetAddress, - PostalCode: values.postalCode, - City: values.city, - State: values.state, - CountryOrRegion: values.country?.value || values.country, - Company: values.companyName, + firstName: values.firstName, + lastName: values.lastName, + jobTitle: values.jobTitle, + streetAddress: values.streetAddress, + postalCode: values.postalCode, + city: values.city, + state: values.state, + country: values.country?.value || values.country, + companyName: values.companyName, mobilePhone: values.mobilePhone, - phone: values.businessPhone, + businessPhone: values.businessPhone, website: values.website, mailTip: values.mailTip, }; }} > - ); diff --git a/src/pages/email/administration/contacts-template/edit.jsx b/src/pages/email/administration/contacts-template/edit.jsx index 987e9f45a3bb..33f40120a1c2 100644 --- a/src/pages/email/administration/contacts-template/edit.jsx +++ b/src/pages/email/administration/contacts-template/edit.jsx @@ -8,8 +8,6 @@ import { ApiGetCall } from "../../../../api/ApiCall"; import countryList from "../../../../data/countryList.json"; import { useRouter } from "next/router"; -const countryLookup = new Map(countryList.map((country) => [country.Name, country.Code])); - const EditContactTemplate = () => { const router = useRouter(); const { id } = router.query; @@ -57,32 +55,33 @@ const EditContactTemplate = () => { const contact = Array.isArray(contactTemplateInfo.data) ? contactTemplateInfo.data[0] : contactTemplateInfo.data; - const address = contact.addresses?.[0] || {}; - const phones = contact.phones || []; - // Use Map for O(1) phone lookup - const phoneMap = new Map(phones.map((p) => [p.type, p.number])); + // The template is stored as a flat object (see Invoke-AddContactTemplates), so read the + // fields directly rather than treating it as a Microsoft Graph contact. + const countryEntry = contact.country + ? countryList.find((c) => c.Code === contact.country || c.Name === contact.country) + : null; return { ContactTemplateID: id || "", displayName: contact.displayName || "", - firstName: contact.givenName || "", - lastName: contact.surname || "", + firstName: contact.firstName || "", + lastName: contact.lastName || "", email: contact.email || "", hidefromGAL: contact.hidefromGAL || false, - streetAddress: address.street || "", - postalCode: address.postalCode || "", - city: address.city || "", - state: address.state || "", - country: address.countryOrRegion ? countryLookup.get(address.countryOrRegion) || "" : "", + streetAddress: contact.streetAddress || "", + postalCode: contact.postalCode || "", + city: contact.city || "", + state: contact.state || "", + country: countryEntry ? { label: countryEntry.Name, value: countryEntry.Code } : "", companyName: contact.companyName || "", - mobilePhone: phoneMap.get("mobile") || "", - businessPhone: phoneMap.get("business") || "", + mobilePhone: contact.mobilePhone || "", + businessPhone: contact.businessPhone || "", jobTitle: contact.jobTitle || "", website: contact.website || "", mailTip: contact.mailTip || "", }; - }, [contactTemplateInfo.isSuccess, contactTemplateInfo.data]); + }, [contactTemplateInfo.isSuccess, contactTemplateInfo.data, id]); // Use callback to prevent unnecessary re-renders const resetForm = useCallback(() => { @@ -96,27 +95,30 @@ const EditContactTemplate = () => { }, [resetForm]); // Memoize custom data formatter - const customDataFormatter = useCallback((values) => { - return { - ContactTemplateID: id, - DisplayName: values.displayName, - hidefromGAL: values.hidefromGAL, - email: values.email, - FirstName: values.firstName, - LastName: values.lastName, - Title: values.jobTitle, - StreetAddress: values.streetAddress, - PostalCode: values.postalCode, - City: values.city, - State: values.state, - CountryOrRegion: values.country?.value || values.country, - Company: values.companyName, - mobilePhone: values.mobilePhone, - phone: values.businessPhone, - website: values.website, - mailTip: values.mailTip, - }; - }); + const customDataFormatter = useCallback( + (values) => { + return { + ContactTemplateID: id, + displayName: values.displayName, + hidefromGAL: values.hidefromGAL, + email: values.email, + firstName: values.firstName, + lastName: values.lastName, + jobTitle: values.jobTitle, + streetAddress: values.streetAddress, + postalCode: values.postalCode, + city: values.city, + state: values.state, + country: values.country?.value || values.country, + companyName: values.companyName, + mobilePhone: values.mobilePhone, + businessPhone: values.businessPhone, + website: values.website, + mailTip: values.mailTip, + }; + }, + [id] + ); const contactTemplate = Array.isArray(contactTemplateInfo.data) ? contactTemplateInfo.data[0] diff --git a/src/pages/email/administration/contacts-template/index.jsx b/src/pages/email/administration/contacts-template/index.jsx index d24604c9a9af..8d5fc6d69239 100644 --- a/src/pages/email/administration/contacts-template/index.jsx +++ b/src/pages/email/administration/contacts-template/index.jsx @@ -81,7 +81,14 @@ const Page = () => { target: "_self", }, ]; - const simpleColumns = ["name", "contactTemplateName", "GUID"]; + const simpleColumns = [ + "displayName", + "email", + "companyName", + "jobTitle", + "hidefromGAL", + "GUID", + ]; return ( { simpleColumns={simpleColumns} cardButton={ <> - +
    {reportDB.syncDialog} - ); -}; + ) +} -Page.getLayout = (page) => {page}; -export default Page; +Page.getLayout = (page) => {page} +export default Page diff --git a/src/pages/endpoint/applications/list/index.js b/src/pages/endpoint/applications/list/index.js index fec79a4cf7f9..525287b3dc33 100644 --- a/src/pages/endpoint/applications/list/index.js +++ b/src/pages/endpoint/applications/list/index.js @@ -1,315 +1,375 @@ -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; -import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; -import { CippApiDialog } from "../../../../components/CippComponents/CippApiDialog.jsx"; -import { GlobeAltIcon, TrashIcon, UserIcon, UserGroupIcon } from "@heroicons/react/24/outline"; -import { LaptopMac, Sync, BookmarkAdd } from "@mui/icons-material"; -import { CippApplicationDeployDrawer } from "../../../../components/CippComponents/CippApplicationDeployDrawer"; -import { Button } from "@mui/material"; -import { Stack } from "@mui/system"; -import { useSettings } from "../../../../hooks/use-settings.js"; -import { useDialog } from "../../../../hooks/use-dialog.js"; -import { useCippReportDB } from "../../../../components/CippComponents/CippReportDBControls"; +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' +import { CippApiDialog } from '../../../../components/CippComponents/CippApiDialog.jsx' +import { GlobeAltIcon, TrashIcon, UserIcon, UserGroupIcon } from '@heroicons/react/24/outline' +import { LaptopMac, Sync, BookmarkAdd } from '@mui/icons-material' +import { CippApplicationDeployDrawer } from '../../../../components/CippComponents/CippApplicationDeployDrawer' +import { Button } from '@mui/material' +import { Stack } from '@mui/system' +import { useSettings } from '../../../../hooks/use-settings.js' +import { useDialog } from '../../../../hooks/use-dialog.js' +import { useCippReportDB } from '../../../../components/CippComponents/CippReportDBControls' const assignmentIntentOptions = [ - { label: "Required", value: "Required" }, - { label: "Available", value: "Available" }, - { label: "Available without enrollment", value: "AvailableWithoutEnrollment" }, - { label: "Uninstall", value: "Uninstall" }, -]; + { label: 'Required', value: 'Required' }, + { label: 'Available', value: 'Available' }, + { label: 'Available without enrollment', value: 'AvailableWithoutEnrollment' }, + { label: 'Uninstall', value: 'Uninstall' }, +] const assignmentModeOptions = [ - { label: "Replace existing assignments", value: "replace" }, - { label: "Append to existing assignments", value: "append" }, -]; + { label: 'Replace existing assignments', value: 'replace' }, + { label: 'Append to existing assignments', value: 'append' }, +] const assignmentFilterTypeOptions = [ - { label: "Include - Apply to devices matching filter", value: "include" }, - { label: "Exclude - Apply to devices NOT matching filter", value: "exclude" }, -]; + { label: 'Include - Apply to devices matching filter', value: 'include' }, + { label: 'Exclude - Apply to devices NOT matching filter', value: 'exclude' }, +] + +const assignmentDirectionOptions = [ + { label: 'Include these group(s)', value: 'include' }, + { label: 'Exclude these group(s)', value: 'exclude' }, +] const getAppAssignmentSettingsType = (odataType) => { - if (!odataType || typeof odataType !== "string") { - return undefined; + if (!odataType || typeof odataType !== 'string') { + return undefined } - return odataType.replace("#microsoft.graph.", "").replace(/App$/i, ""); -}; + return odataType.replace('#microsoft.graph.', '').replace(/App$/i, '') +} const mapOdataToAppType = (odataType) => { - if (!odataType) return "win32ScriptApp"; - const type = odataType.toLowerCase(); - if (type.includes("wingetapp")) return "StoreApp"; - if (type.includes("win32lobapp")) return "chocolateyApp"; - if (type.includes("officesuiteapp")) return "officeApp"; - return "win32ScriptApp"; -}; + if (!odataType) return 'win32ScriptApp' + const type = odataType.toLowerCase() + if (type.includes('wingetapp')) return 'StoreApp' + if (type.includes('win32lobapp')) return 'chocolateyApp' + if (type.includes('officesuiteapp')) return 'officeApp' + return 'win32ScriptApp' +} const Page = () => { - const pageTitle = "Applications"; - const vppSyncDialog = useDialog(); - const tenant = useSettings().currentTenant; + const pageTitle = 'Applications' + const vppSyncDialog = useDialog() + const tenant = useSettings().currentTenant const reportDB = useCippReportDB({ - apiUrl: "/api/ListApps", - queryKey: "ListApps", - cacheName: "IntuneApplications", - syncTitle: "Sync Intune Applications Report", + apiUrl: '/api/ListApps', + queryKey: 'ListApps', + cacheName: 'IntuneApplications', + syncTitle: 'Sync Intune Applications Report', allowToggle: true, defaultCached: false, - }); + }) const getAssignmentFilterFields = () => [ { - type: "autoComplete", - name: "assignmentFilter", - label: "Assignment Filter (Optional)", + type: 'autoComplete', + name: 'assignmentFilter', + label: 'Assignment Filter (Optional)', multiple: false, creatable: false, api: { - url: "/api/ListAssignmentFilters", + url: '/api/ListAssignmentFilters', queryKey: `ListAssignmentFilters-${tenant}`, labelField: (filter) => filter.displayName, - valueField: "displayName", + valueField: 'displayName', }, }, { - type: "radio", - name: "assignmentFilterType", - label: "Assignment Filter Mode", + type: 'radio', + name: 'assignmentFilterType', + label: 'Assignment Filter Mode', options: assignmentFilterTypeOptions, - defaultValue: "include", - helperText: "Choose whether to include or exclude devices matching the filter.", + defaultValue: 'include', + helperText: 'Choose whether to include or exclude devices matching the filter.', + condition: { field: 'assignmentFilter', compareType: 'hasValue', clearOnHide: false }, }, - ]; + ] // Builds a customDataformatter that handles both single-row and bulk (array) inputs. const makeAssignFormatter = (getRowData) => (row, action, formData) => { const formatRow = (singleRow) => { const tenantFilterValue = - tenant === "AllTenants" && singleRow?.Tenant ? singleRow.Tenant : tenant; + tenant === 'AllTenants' && singleRow?.Tenant ? singleRow.Tenant : tenant return { tenantFilter: tenantFilterValue, ID: singleRow?.id, - AppType: getAppAssignmentSettingsType(singleRow?.["@odata.type"]), + AppType: getAppAssignmentSettingsType(singleRow?.['@odata.type']), AssignmentFilterName: formData?.assignmentFilter?.value || null, AssignmentFilterType: formData?.assignmentFilter?.value - ? formData?.assignmentFilterType || "include" + ? formData?.assignmentFilterType || 'include' : null, + ExcludeGroupIds: (formData?.excludeGroupTargets || []).map((g) => g.value).filter(Boolean), + ExcludeGroupNames: (formData?.excludeGroupTargets || []) + .map((g) => g.label) + .filter(Boolean), ...getRowData(singleRow, formData), - }; - }; - return Array.isArray(row) ? row.map(formatRow) : formatRow(row); - }; + } + } + return Array.isArray(row) ? row.map(formatRow) : formatRow(row) + } + + // Group picker (by ID) reused for both include and exclude selection + const getGroupPickerField = (name, label, required) => ({ + type: 'autoComplete', + name, + label, + multiple: true, + creatable: false, + allowResubmit: true, + ...(required && { validators: { required: 'Please select at least one group' } }), + api: { + url: '/api/ListGraphRequest', + dataKey: 'Results', + queryKey: `ListAppAssignmentGroups-${tenant}`, + labelField: (group) => (group.id ? `${group.displayName} (${group.id})` : group.displayName), + valueField: 'id', + addedField: { + description: 'description', + }, + data: { + Endpoint: 'groups', + manualPagination: true, + $select: 'id,displayName,description', + $orderby: 'displayName', + $top: 999, + $count: true, + }, + }, + }) const assignmentFields = [ + { type: 'heading', label: 'Exclude groups (optional)' }, + getGroupPickerField('excludeGroupTargets', 'Exclude group(s)', false), + { type: 'heading', label: 'Assignment options' }, { - type: "radio", - name: "Intent", - label: "Assignment intent", + type: 'radio', + name: 'Intent', + label: 'Assignment intent', options: assignmentIntentOptions, - defaultValue: "Required", - validators: { required: "Select an assignment intent" }, + defaultValue: 'Required', + validators: { required: 'Select an assignment intent' }, helperText: - "Available assigns to Company Portal, Required installs automatically, Uninstall removes the app, Available without enrollment exposes it without device enrollment.", + 'Available assigns to Company Portal, Required installs automatically, Uninstall removes the app, Available without enrollment exposes it without device enrollment.', }, { - type: "radio", - name: "assignmentMode", - label: "Assignment mode", + type: 'radio', + name: 'assignmentMode', + label: 'Assignment mode', options: assignmentModeOptions, - defaultValue: "replace", + defaultValue: 'replace', helperText: - "Replace will overwrite existing assignments. Append keeps current assignments and adds/overwrites only for the selected groups/intents.", + 'Replace will overwrite existing assignments. Append keeps current assignments and adds/overwrites only for the selected groups/intents.', }, + { type: 'heading', label: 'Device filter (optional)' }, ...getAssignmentFilterFields(), - ]; + ] const actions = [ { - label: "Assign to All Users", - type: "POST", - url: "/api/ExecAssignApp", + label: 'Assign to All Users', + type: 'POST', + url: '/api/ExecAssignApp', + allowResubmit: true, fields: assignmentFields, customDataformatter: makeAssignFormatter((_singleRow, formData) => ({ - AssignTo: "AllUsers", - Intent: formData?.Intent || "Required", - assignmentMode: formData?.assignmentMode || "replace", + AssignTo: 'AllUsers', + Intent: formData?.Intent || 'Required', + assignmentMode: formData?.assignmentMode || 'replace', })), confirmText: 'Are you sure you want to assign "[displayName]" to all users?', icon: , - color: "info", + color: 'info', }, { - label: "Assign to All Devices", - type: "POST", - url: "/api/ExecAssignApp", + label: 'Assign to All Devices', + type: 'POST', + url: '/api/ExecAssignApp', + allowResubmit: true, fields: assignmentFields, customDataformatter: makeAssignFormatter((_singleRow, formData) => ({ - AssignTo: "AllDevices", - Intent: formData?.Intent || "Required", - assignmentMode: formData?.assignmentMode || "replace", + AssignTo: 'AllDevices', + Intent: formData?.Intent || 'Required', + assignmentMode: formData?.assignmentMode || 'replace', })), confirmText: 'Are you sure you want to assign "[displayName]" to all devices?', icon: , - color: "info", + color: 'info', }, { - label: "Assign Globally (All Users / All Devices)", - type: "POST", - url: "/api/ExecAssignApp", + label: 'Assign Globally (All Users / All Devices)', + type: 'POST', + url: '/api/ExecAssignApp', + allowResubmit: true, fields: assignmentFields, customDataformatter: makeAssignFormatter((_singleRow, formData) => ({ - AssignTo: "AllDevicesAndUsers", - Intent: formData?.Intent || "Required", - assignmentMode: formData?.assignmentMode || "replace", + AssignTo: 'AllDevicesAndUsers', + Intent: formData?.Intent || 'Required', + assignmentMode: formData?.assignmentMode || 'replace', })), confirmText: 'Are you sure you want to assign "[displayName]" to all users and devices?', icon: , - color: "info", + color: 'info', }, { - label: "Assign to Custom Group", - type: "POST", - url: "/api/ExecAssignApp", + label: 'Assign to Custom Group', + type: 'POST', + url: '/api/ExecAssignApp', + allowResubmit: true, icon: , - color: "info", + color: 'info', confirmText: 'Select the target groups and intent for "[displayName]".', fields: [ + { type: 'heading', label: 'Target groups' }, { - type: "autoComplete", - name: "groupTargets", - label: "Group(s)", - multiple: true, - creatable: false, - allowResubmit: true, - validators: { required: "Please select at least one group" }, - api: { - url: "/api/ListGraphRequest", - dataKey: "Results", - queryKey: `ListAppAssignmentGroups-${tenant}`, - labelField: (group) => - group.id ? `${group.displayName} (${group.id})` : group.displayName, - valueField: "id", - addedField: { - description: "description", - }, - data: { - Endpoint: "groups", - manualPagination: true, - $select: "id,displayName,description", - $orderby: "displayName", - $top: 999, - $count: true, + ...getGroupPickerField('groupTargets', 'Group(s)', false), + helperText: + 'Leave empty with Exclude + Replace to remove all exclusions (keeps includes).', + validators: { + // Required, except Exclude + Replace where an empty selection clears all exclusions. + validate: (value, formValues) => { + if ( + formValues?.assignmentDirection === 'exclude' && + (formValues?.assignmentMode || 'replace') === 'replace' + ) { + return true + } + return ( + (Array.isArray(value) && value.length > 0) || 'Please select at least one group' + ) }, }, }, { - type: "radio", - name: "assignmentIntent", - label: "Assignment intent", + type: 'radio', + name: 'assignmentDirection', + label: 'Assignment direction', + options: assignmentDirectionOptions, + defaultValue: 'include', + // Re-validate the picker so the empty-allowed rule updates when direction changes. + validators: { deps: ['groupTargets'] }, + helperText: + 'Include assigns to these groups; Exclude excludes them. Replace updates only this direction and keeps the other (and All Users/All Devices) intact.', + }, + { type: 'heading', label: 'Assignment options' }, + { + type: 'radio', + name: 'assignmentIntent', + label: 'Assignment intent', options: assignmentIntentOptions, - defaultValue: "Required", - validators: { required: "Select an assignment intent" }, + defaultValue: 'Required', + validators: { required: 'Select an assignment intent' }, helperText: - "Available assigns to Company Portal, Required installs automatically, Uninstall removes the app, Available without enrollment exposes it without device enrollment.", + 'Available assigns to Company Portal, Required installs automatically, Uninstall removes the app, Available without enrollment exposes it without device enrollment.', }, { - type: "radio", - name: "assignmentMode", - label: "Assignment mode", + type: 'radio', + name: 'assignmentMode', + label: 'Assignment mode', options: assignmentModeOptions, - defaultValue: "replace", + defaultValue: 'replace', + // Re-validate the picker so the empty-allowed rule updates when mode changes. + validators: { deps: ['groupTargets'] }, helperText: - "Replace will overwrite existing assignments. Append keeps current assignments and adds/overwrites only for the selected groups/intents.", + 'Replace updates only the selected direction and keeps the other direction plus All Users/All Devices. Append adds the selected groups to existing assignments.', }, + { type: 'heading', label: 'Device filter (optional)' }, ...getAssignmentFilterFields(), ], customDataformatter: makeAssignFormatter((_singleRow, formData) => { - const selectedGroups = Array.isArray(formData?.groupTargets) ? formData.groupTargets : []; + const selectedGroups = Array.isArray(formData?.groupTargets) ? formData.groupTargets : [] + const isExclude = formData?.assignmentDirection === 'exclude' + const ids = selectedGroups.map((group) => group.value).filter(Boolean) + const names = selectedGroups.map((group) => group.label).filter(Boolean) return { - GroupIds: selectedGroups.map((group) => group.value).filter(Boolean), - GroupNames: selectedGroups.map((group) => group.label).filter(Boolean), - Intent: formData?.assignmentIntent || "Required", - AssignmentMode: formData?.assignmentMode || "replace", - }; + GroupIds: isExclude ? [] : ids, + GroupNames: isExclude ? [] : names, + ExcludeGroupIds: isExclude ? ids : [], + ExcludeGroupNames: isExclude ? names : [], + assignmentDirection: formData?.assignmentDirection || 'include', + Intent: formData?.assignmentIntent || 'Required', + AssignmentMode: formData?.assignmentMode || 'replace', + } }), }, { - label: "Save as Template", - type: "POST", - url: "/api/AddAppTemplate", + label: 'Save as Template', + type: 'POST', + url: '/api/AddAppTemplate', icon: , - color: "info", + color: 'info', fields: [ { - type: "textField", - name: "displayName", - label: "Template Name", - validators: { required: "Template name is required" }, + type: 'textField', + name: 'displayName', + label: 'Template Name', + validators: { required: 'Template name is required' }, }, { - type: "textField", - name: "description", - label: "Description", + type: 'textField', + name: 'description', + label: 'Description', }, ], customDataformatter: (row, action, formData) => { - const rows = Array.isArray(row) ? row : [row]; + const rows = Array.isArray(row) ? row : [row] return { displayName: formData?.displayName, - description: formData?.description || "", + description: formData?.description || '', apps: rows.map((r) => ({ - appType: mapOdataToAppType(r["@odata.type"]), + appType: mapOdataToAppType(r['@odata.type']), appName: r.displayName, config: JSON.stringify({ ApplicationName: r.displayName, IntuneBody: r, - assignTo: "On", + assignTo: 'On', }), })), - }; + } }, confirmText: 'Save selected application(s) as a reusable template?', }, { - label: "Delete Application", - type: "POST", - url: "/api/RemoveApp", + label: 'Delete Application', + type: 'POST', + url: '/api/RemoveApp', data: { - ID: "id", + ID: 'id', }, confirmText: 'Are you sure you want to delete "[displayName]"?', icon: , - color: "danger", + color: 'danger', }, - ]; + ] const offCanvas = { extendedInfoFields: [ - "installExperience.runAsAccount", - "installExperience.deviceRestartBehavior", - "isAssigned", - "createdDateTime", - "lastModifiedDateTime", - "isFeatured", - "publishingState", - "dependentAppCount", - "rules.0.ruleType", - "rules.0.fileOrFolderName", - "rules.0.path", + 'installExperience.runAsAccount', + 'installExperience.deviceRestartBehavior', + 'isAssigned', + 'createdDateTime', + 'lastModifiedDateTime', + 'isFeatured', + 'publishingState', + 'dependentAppCount', + 'rules.0.ruleType', + 'rules.0.fileOrFolderName', + 'rules.0.path', ], actions: actions, - }; + } const simpleColumns = [ ...reportDB.cacheColumns, - "displayName", - "AppAssignment", - "AppExclude", - "publishingState", - "lastModifiedDateTime", - "createdDateTime", - ]; + 'displayName', + 'AppAssignment', + 'AppExclude', + 'publishingState', + 'lastModifiedDateTime', + 'createdDateTime', + ] return ( <> @@ -334,16 +394,16 @@ const Page = () => { title="Sync VPP Tokens" createDialog={vppSyncDialog} api={{ - type: "POST", - url: "/api/ExecSyncVPP", + type: 'POST', + url: '/api/ExecSyncVPP', data: {}, confirmText: `Are you sure you want to sync Apple Volume Purchase Program (VPP) tokens? This will sync all VPP tokens for ${tenant}.`, }} /> {reportDB.syncDialog} - ); -}; + ) +} -Page.getLayout = (page) => {page}; -export default Page; +Page.getLayout = (page) => {page} +export default Page diff --git a/src/pages/endpoint/autopilot/list-devices/index.js b/src/pages/endpoint/autopilot/list-devices/index.js index c6694e3d1920..af8d7765b4c8 100644 --- a/src/pages/endpoint/autopilot/list-devices/index.js +++ b/src/pages/endpoint/autopilot/list-devices/index.js @@ -29,7 +29,16 @@ const Page = () => { multiple: false, creatable: false, api: { - url: '/api/listUsers', + url: '/api/ListGraphRequest', + dataKey: 'Results', + data: { + Endpoint: 'users', + manualPagination: true, + $select: 'id,userPrincipalName,displayName', + $count: true, + $orderby: 'displayName', + $top: 999, + }, labelField: (user) => `${user.displayName} (${user.userPrincipalName})`, valueField: 'userPrincipalName', addedField: { diff --git a/src/pages/identity/administration/groups/edit.jsx b/src/pages/identity/administration/groups/edit.jsx index a4ef9e9d5cb6..5e37a98ad364 100644 --- a/src/pages/identity/administration/groups/edit.jsx +++ b/src/pages/identity/administration/groups/edit.jsx @@ -1,16 +1,17 @@ -import { useEffect, useState } from 'react' -import { Box, Button, Divider, Typography, Alert } from '@mui/material' -import { Grid } from '@mui/system' -import { useForm } from 'react-hook-form' -import { Layout as DashboardLayout } from '../../../../layouts/index.js' -import CippFormPage from '../../../../components/CippFormPages/CippFormPage' -import CippFormComponent from '../../../../components/CippComponents/CippFormComponent' -import { CippFormUserSelector } from '../../../../components/CippComponents/CippFormUserSelector' -import { useRouter } from 'next/router' -import { ApiGetCall } from '../../../../api/ApiCall' -import { useSettings } from '../../../../hooks/use-settings' -import { CippFormContactSelector } from '../../../../components/CippComponents/CippFormContactSelector' -import { CippDataTable } from '../../../../components/CippTable/CippDataTable' +import { useEffect, useState } from "react"; +import { Box, Button, Divider, Typography, Alert } from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm } from "react-hook-form"; +import { Layout as DashboardLayout } from "../../../../layouts/index.js"; +import CippFormPage from "../../../../components/CippFormPages/CippFormPage"; +import CippFormComponent from "../../../../components/CippComponents/CippFormComponent"; +import { CippFormUserSelector } from "../../../../components/CippComponents/CippFormUserSelector"; +import { CippFormUserAndGroupSelector } from "../../../../components/CippComponents/CippFormUserAndGroupSelector"; +import { useRouter } from "next/router"; +import { ApiGetCall } from "../../../../api/ApiCall"; +import { useSettings } from "../../../../hooks/use-settings"; +import { CippFormContactSelector } from "../../../../components/CippComponents/CippFormContactSelector"; +import { CippDataTable } from "../../../../components/CippTable/CippDataTable"; import { CippFormLicenseSelector } from '../../../../components/CippComponents/CippFormLicenseSelector' import { getCippLicenseTranslation } from '../../../../utils/get-cipp-license-translation' @@ -251,10 +252,10 @@ const EditGroup = () => { - { m?.['@odata.type'] !== '#microsoft.graph.orgContact') - ?.map((m) => ({ - label: `${m.displayName} (${m.userPrincipalName})`, - value: m.id, - addedFields: { - userPrincipalName: m.userPrincipalName, - displayName: m.displayName, - id: m.id, - }, - })) || [] + ?.filter((m) => m?.["@odata.type"] !== "#microsoft.graph.orgContact") + ?.map((m) => { + const groupType = m.mailEnabled && !m.securityEnabled + ? "Distribution Group" + : m.mailEnabled && m.securityEnabled + ? "Mail-Enabled Security Group" + : "Security Group"; + return { + label: m.userPrincipalName + ? `${m.displayName} (${m.userPrincipalName})` + : `${m.displayName} (${groupType})`, + value: m.id, + addedFields: { + userPrincipalName: m.userPrincipalName, + displayName: m.displayName, + id: m.id, + }, + }; + }) || [] } sortOptions={true} /> diff --git a/src/pages/identity/administration/users/index.js b/src/pages/identity/administration/users/index.js index a8942377f3ea..be8276449daf 100644 --- a/src/pages/identity/administration/users/index.js +++ b/src/pages/identity/administration/users/index.js @@ -1,67 +1,67 @@ -import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; -import { useSettings } from "../../../../hooks/use-settings.js"; -import { PermissionButton } from "../../../../utils/permissions"; -import { useCippUserActions } from "../../../../components/CippComponents/CippUserActions.jsx"; -import { CippInviteGuestDrawer } from "../../../../components/CippComponents/CippInviteGuestDrawer.jsx"; -import { CippBulkInviteGuestDrawer } from "../../../../components/CippComponents/CippBulkInviteGuestDrawer.jsx"; -import { CippBulkUserDrawer } from "../../../../components/CippComponents/CippBulkUserDrawer.jsx"; -import { CippAddUserDrawer } from "../../../../components/CippComponents/CippAddUserDrawer.jsx"; -import { CippApiLogsDrawer } from "../../../../components/CippComponents/CippApiLogsDrawer.jsx"; -import { Box } from "@mui/material"; +import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { useSettings } from '../../../../hooks/use-settings.js' +import { PermissionButton } from '../../../../utils/permissions' +import { useCippUserActions } from '../../../../components/CippComponents/CippUserActions.jsx' +import { CippInviteGuestDrawer } from '../../../../components/CippComponents/CippInviteGuestDrawer.jsx' +import { CippBulkInviteGuestDrawer } from '../../../../components/CippComponents/CippBulkInviteGuestDrawer.jsx' +import { CippBulkUserDrawer } from '../../../../components/CippComponents/CippBulkUserDrawer.jsx' +import { CippAddUserDrawer } from '../../../../components/CippComponents/CippAddUserDrawer.jsx' +import { CippApiLogsDrawer } from '../../../../components/CippComponents/CippApiLogsDrawer.jsx' +import { Box } from '@mui/material' const Page = () => { - const userActions = useCippUserActions(); - const pageTitle = "Users"; - const tenant = useSettings().currentTenant; - const cardButtonPermissions = ["Identity.User.ReadWrite"]; + const userActions = useCippUserActions() + const pageTitle = 'Users' + const tenant = useSettings().currentTenant + const cardButtonPermissions = ['Identity.User.ReadWrite'] const filters = [ { - filterName: "Account Enabled", - value: [{ id: "accountEnabled", value: "Yes" }], - type: "column", + filterName: 'Account Enabled', + value: [{ id: 'accountEnabled', value: 'Yes' }], + type: 'column', }, { - filterName: "Account Disabled", - value: [{ id: "accountEnabled", value: "No" }], - type: "column", + filterName: 'Account Disabled', + value: [{ id: 'accountEnabled', value: 'No' }], + type: 'column', }, { - filterName: "Guest Accounts", - value: [{ id: "userType", value: "Guest" }], - type: "column", + filterName: 'Guest Accounts', + value: [{ id: 'userType', value: 'Guest' }], + type: 'column', }, - ]; + ] const offCanvas = { extendedInfoFields: [ - "createdDateTime", // Created Date (UTC) - "id", // Unique ID - "userPrincipalName", // UPN - "givenName", // Given Name - "surname", // Surname - "jobTitle", // Job Title - "assignedLicenses", // Licenses - "businessPhones", // Business Phone - "mobilePhone", // Mobile Phone - "mail", // Mail - "city", // City - "department", // Department - "onPremisesLastSyncDateTime", // OnPrem Last Sync - "onPremisesDistinguishedName", // OnPrem DN - "otherMails", // Alternate Email Addresses - "licenseAssignmentStates", // License Assignment States + 'createdDateTime', // Created Date (UTC) + 'id', // Unique ID + 'userPrincipalName', // UPN + 'givenName', // Given Name + 'surname', // Surname + 'jobTitle', // Job Title + 'assignedLicenses', // Licenses + 'businessPhones', // Business Phone + 'mobilePhone', // Mobile Phone + 'mail', // Mail + 'city', // City + 'department', // Department + 'onPremisesLastSyncDateTime', // OnPrem Last Sync + 'onPremisesDistinguishedName', // OnPrem DN + 'otherMails', // Alternate Email Addresses + 'licenseAssignmentStates', // License Assignment States ], actions: userActions, - }; + } return ( + { } apiData={{ - Endpoint: "users", + Endpoint: 'users', manualPagination: true, $select: - "id,accountEnabled,businessPhones,city,createdDateTime,companyName,country,department,displayName,faxNumber,givenName,isResourceAccount,jobTitle,mail,mailNickname,mobilePhone,officeLocation,otherMails,postalCode,preferredDataLocation,preferredLanguage,proxyAddresses,showInAddressList,state,streetAddress,surname,usageLocation,userPrincipalName,userType,assignedLicenses,licenseAssignmentStates,onPremisesSyncEnabled,OnPremisesImmutableId,onPremisesLastSyncDateTime,onPremisesDistinguishedName", + 'id,accountEnabled,businessPhones,city,createdDateTime,companyName,country,department,displayName,faxNumber,givenName,isResourceAccount,jobTitle,mail,mailNickname,mobilePhone,officeLocation,otherMails,postalCode,preferredDataLocation,preferredLanguage,proxyAddresses,showInAddressList,state,streetAddress,surname,usageLocation,userPrincipalName,userType,assignedLicenses,licenseAssignmentStates,onPremisesSyncEnabled,OnPremisesImmutableId,onPremisesLastSyncDateTime,onPremisesDistinguishedName', $count: true, - $orderby: "displayName", + $orderby: 'displayName', $top: 999, }} apiDataKey="Results" actions={userActions} offCanvas={offCanvas} simpleColumns={[ - "accountEnabled", - "userPrincipalName", - "displayName", - "mail", - "businessPhones", - "proxyAddresses", - "assignedLicenses", - "licenseAssignmentStates", + 'accountEnabled', + 'userPrincipalName', + 'displayName', + 'mail', + 'businessPhones', + 'proxyAddresses', + 'assignedLicenses', + 'licenseAssignmentStates', + 'userType', ]} filters={filters} /> - ); -}; + ) +} -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page} -export default Page; +export default Page diff --git a/src/pages/identity/administration/users/user/bec.jsx b/src/pages/identity/administration/users/user/bec.jsx index a5b44d5a7893..4cef9d6190b8 100644 --- a/src/pages/identity/administration/users/user/bec.jsx +++ b/src/pages/identity/administration/users/user/bec.jsx @@ -147,6 +147,14 @@ const Page = () => { return "No mailbox permission changes found."; }; + const getSentMessagesMessage = () => { + if (!becPollingCall.data) return null; + if (becPollingCall.data.SentMessages && becPollingCall.data.SentMessages.length > 0) { + return "Sent messages have been found. Please review the list below for any suspicious activity."; + } + return "No sent messages found in the specified time range."; + }; + const subtitle = userRequest.isSuccess ? [ { @@ -459,12 +467,56 @@ const Page = () => { )} + {/* Check 5: Sent Messages */} + + Check 5: Sent Messages + + {becPollingCall.data && + becPollingCall.data.SentMessages && + becPollingCall.data.SentMessages.length > 0 ? ( + + + + ) : ( + + + + )} + + + } + > + + {getSentMessagesMessage()} + + {/* Display sent messages */} + {becPollingCall.data && + becPollingCall.data.SentMessages && + becPollingCall.data.SentMessages.length > 0 && ( + + + {becPollingCall.data.SentMessages.map((message, index) => ( + + ))} + + + )} + + - Check 5: MFA Devices + Check 6: MFA Devices {becPollingCall.data && becPollingCall.data.MFADevices && @@ -508,7 +560,7 @@ const Page = () => { isFetching={false} title={ - Check 6: Password Changes + Check 7: Password Changes {becPollingCall.data && becPollingCall.data.ChangedPasswords && @@ -546,7 +598,7 @@ const Page = () => { )} - {/* Check 6: Report Data */} + {/* Check 8: Report Data */} { - const userSettingsDefaults = useSettings(); - const router = useRouter(); - const { userId } = router.query; - const [waiting, setWaiting] = useState(false); + const userSettingsDefaults = useSettings() + const router = useRouter() + const { userId } = router.query + const [waiting, setWaiting] = useState(false) const userRequest = ApiGetCall({ url: `/api/ListUsers?UserId=${userId}&tenantFilter=${userSettingsDefaults.currentTenant}`, queryKey: `ListUsers-${userId}`, waiting: waiting, - }); + }) // add useEffect to refetch user data when userId changes - also set waiting to false if userId is undefined useEffect(() => { if (userId !== undefined) { - setWaiting(true); - userRequest.refetch(); + setWaiting(true) + userRequest.refetch() } else { - setWaiting(false); + setWaiting(false) } - }, [userId, waiting]); + }, [userId, waiting]) const formControl = useForm({ - mode: "onBlur", + mode: 'onBlur', defaultValues: { tenantFilter: userSettingsDefaults.currentTenant, }, - }); + }) + + // Subscribe to dirtyFields during render — RHF's formState proxy only populates the granular + // dirtyFields object when something observes it. Without this, formState.dirtyFields stays {}. + const { dirtyFields } = useFormState({ control: formControl.control }) useEffect(() => { if (userRequest.isSuccess) { - const user = userRequest.data?.[0]; + const user = userRequest.data?.[0] //if we have userSettingsDefaults.userAttributes set, grab the .label from each userSsettingsDefaults, then set defaultAttributes.${label}.value to user.${label} - let defaultAttributes = {}; + let defaultAttributes = {} if (userSettingsDefaults.userAttributes) { userSettingsDefaults.userAttributes.forEach((attribute) => { - defaultAttributes[attribute.label] = { Value: user?.[attribute.label] }; - }); + defaultAttributes[attribute.label] = { Value: user?.[attribute.label] } + }) } // Use fallback for usageLocation if user's usageLocation is null/undefined - const usageLocation = user?.usageLocation || userSettingsDefaults?.usageLocation || null; + const usageLocation = user?.usageLocation || userSettingsDefaults?.usageLocation || null formControl.reset({ ...user, @@ -68,13 +72,48 @@ const Page = () => { label: getCippLicenseTranslation([license]), value: license.skuId, })), - }); - formControl.trigger(); + }) + formControl.trigger() } - }, [userRequest.isSuccess, userRequest.data, userRequest.isLoading]); + }, [userRequest.isSuccess, userRequest.data, userRequest.isLoading]) + + // Profile fields where blanking the box should clear the property in Entra. + // Only fields the user actively emptied (dirty) are reported in clearProperties so untouched-empty + // fields and partial API callers are left alone. + const clearableFields = [ + 'givenName', + 'surname', + 'jobTitle', + 'department', + 'companyName', + 'mobilePhone', + 'streetAddress', + 'city', + 'state', + 'postalCode', + 'country', + ] + const formatEditUser = (values) => { + const dirty = dirtyFields + const isEmpty = (v) => + v === '' || + v == null || + (Array.isArray(v) && v.filter((x) => x !== '' && x != null).length === 0) + // List only the fields the user actively emptied; the backend clears these explicitly. + // We delete the blank value itself so removeEmpty keeps the payload lean (no global null preservation). + const clearProperties = [] + ;[...clearableFields, 'businessPhones', 'otherMails'].forEach((f) => { + if (dirty?.[f] && isEmpty(values[f])) { + clearProperties.push(f) + delete values[f] + } + }) + if (clearProperties.length) values.clearProperties = clearProperties + return values + } // Set the title and subtitle for the layout - const title = userRequest.isSuccess ? userRequest.data?.[0]?.displayName : "Loading..."; + const title = userRequest.isSuccess ? userRequest.data?.[0]?.displayName : 'Loading...' const subtitle = userRequest.isSuccess ? [ @@ -95,7 +134,7 @@ const Page = () => { ), }, { - icon: , + icon: , text: ( + + )} + + + : } + label={useReportDB ? 'Cached' : 'Live'} + color="primary" + size="small" + onClick={isAllTenants ? undefined : () => setUseReportDB((prev) => !prev)} + clickable={!isAllTenants} + disabled={isAllTenants} + variant="outlined" + /> + + + , + ] + + return ( + <> + + {pageActions} + + } + /> + { + if (result?.Metadata?.QueueId) { + setSyncQueueId(result?.Metadata?.QueueId) + } + }, + }} + /> + + ) +} + +Page.getLayout = (page) => {page} +export default Page diff --git a/src/pages/security/defender/deployment/index.js b/src/pages/security/defender/deployment/index.js index b1012ad0f3a7..5567ba24d576 100644 --- a/src/pages/security/defender/deployment/index.js +++ b/src/pages/security/defender/deployment/index.js @@ -138,6 +138,12 @@ const DeployDefenderForm = () => { formControl={formControl} /> +
    @@ -249,7 +255,7 @@ const DeployDefenderForm = () => { > @@ -259,6 +265,20 @@ const DeployDefenderForm = () => { name="Compliance.AppSync" formControl={formControl} /> + + + { /> - + field="Compliance.allowPartnerToCollectIosCertificateMetadata" + compareType="is" + compareValue={true} + action="disable" + > + + diff --git a/src/pages/security/incidents/list-check-alerts/index.js b/src/pages/security/incidents/list-check-alerts/index.js index db56faf0454e..2586b6ad7167 100644 --- a/src/pages/security/incidents/list-check-alerts/index.js +++ b/src/pages/security/incidents/list-check-alerts/index.js @@ -14,7 +14,7 @@ const Page = () => { docs.check.tech {" "} - or install the plugin now: + or install the plugin now: { > Microsoft Edge {" "} - | + | { - const d = new Date(); - d.setDate(d.getDate() - 30); - d.setHours(0, 0, 0, 0); - return Math.floor(d.getTime() / 1000); -})(); + const d = new Date() + d.setDate(d.getDate() - 30) + d.setHours(0, 0, 0, 0) + return Math.floor(d.getTime() / 1000) +})() const Page = () => { - const pageTitle = "Incidents List"; - const userSettingsDefaults = useSettings(); + const pageTitle = 'Incidents List' + const userSettingsDefaults = useSettings() - const formControl = useForm({ defaultValues: { startDate: defaultStartDate, endDate: null } }); - const [expanded, setExpanded] = useState(false); - const [filterEnabled, setFilterEnabled] = useState(true); + const formControl = useForm({ defaultValues: { startDate: defaultStartDate, endDate: null } }) + const [expanded, setExpanded] = useState(false) + const [filterEnabled, setFilterEnabled] = useState(true) const [startDate, setStartDate] = useState( - new Date(defaultStartDate * 1000).toISOString().split("T")[0].replace(/-/g, ""), - ); - const [endDate, setEndDate] = useState(null); + new Date(defaultStartDate * 1000).toISOString().split('T')[0].replace(/-/g, '') + ) + const [endDate, setEndDate] = useState(null) const onSubmit = (data) => { setStartDate( data.startDate - ? new Date(data.startDate * 1000).toISOString().split("T")[0].replace(/-/g, "") - : null, - ); + ? new Date(data.startDate * 1000).toISOString().split('T')[0].replace(/-/g, '') + : null + ) setEndDate( data.endDate - ? new Date(data.endDate * 1000).toISOString().split("T")[0].replace(/-/g, "") - : null, - ); - setFilterEnabled(data.startDate !== null || data.endDate !== null); - setExpanded(false); - }; + ? new Date(data.endDate * 1000).toISOString().split('T')[0].replace(/-/g, '') + : null + ) + setFilterEnabled(data.startDate !== null || data.endDate !== null) + setExpanded(false) + } const clearFilters = () => { - formControl.reset({ startDate: null, endDate: null }); - setFilterEnabled(false); - setStartDate(null); - setEndDate(null); - setExpanded(false); - }; + formControl.reset({ startDate: null, endDate: null }) + setFilterEnabled(false) + setStartDate(null) + setEndDate(null) + setExpanded(false) + } const fmt = (yyyymmdd) => yyyymmdd ? new Date( - yyyymmdd.replace(/(\d{4})(\d{2})(\d{2})/, "$1-$2-$3") + "T00:00:00", + yyyymmdd.replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3') + 'T00:00:00' ).toLocaleDateString() - : null; + : null const tableFilter = ( setExpanded(v)}> }> - + @@ -82,7 +82,7 @@ const Page = () => { : startDate ? `Date Filter: From ${fmt(startDate)}` : `Date Filter: Up to ${fmt(endDate)}` - : "Date Filter"} + : 'Date Filter'} @@ -127,89 +127,123 @@ const Page = () => { - ); + ) // Define actions for incidents const actions = [ { - label: "Assign to self", - type: "POST", + label: 'Assign to self', + type: 'POST', icon: , - url: "/api/ExecSetSecurityIncident", + url: '/api/ExecSetSecurityIncident', data: { - GUID: "Id", + GUID: 'Id', + AssignToSelf: true, }, - confirmText: "Are you sure you want to assign this incident to yourself?", + confirmText: 'Are you sure you want to assign this incident to yourself?', }, { - label: "Set status to active", - type: "POST", + label: 'Set status to active', + type: 'POST', icon: , - url: "/api/ExecSetSecurityIncident", + url: '/api/ExecSetSecurityIncident', data: { - GUID: "Id", - Status: "!active", - Assigned: "AssignedTo", + GUID: 'Id', + Status: '!active', }, - confirmText: "Are you sure you want to set the status to active?", + confirmText: 'Are you sure you want to set the status to active?', }, { - label: "Set status to in progress", - type: "POST", + label: 'Set status to in progress', + type: 'POST', icon: , - url: "/api/ExecSetSecurityIncident", + url: '/api/ExecSetSecurityIncident', data: { - GUID: "Id", - Status: "!inProgress", - Assigned: "AssignedTo", + GUID: 'Id', + Status: '!inProgress', }, - confirmText: "Are you sure you want to set the status to in progress?", + confirmText: 'Are you sure you want to set the status to in progress?', }, { - label: "Set status to resolved", - type: "POST", + label: 'Set status to resolved', + type: 'POST', icon: , - url: "/api/ExecSetSecurityIncident", + url: '/api/ExecSetSecurityIncident', data: { - GUID: "Id", - Status: "!resolved", - Assigned: "AssignedTo", + GUID: 'Id', + Status: '!resolved', }, - confirmText: "Are you sure you want to set the status to resolved?", + fields: [ + { + type: 'textField', + name: 'Comment', + label: 'Resolving comment (optional)', + multiline: true, + rows: 3, + }, + ], + confirmText: 'Are you sure you want to set the status to resolved?', }, - ]; + { + label: 'Set severity', + type: 'POST', + icon: , + url: '/api/ExecSetSecurityIncident', + data: { + GUID: 'Id', + }, + fields: [ + { + type: 'autoComplete', + name: 'Severity', + label: 'Severity', + multiple: false, + creatable: false, + options: [ + { value: 'informational', label: 'Informational' }, + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, + ], + validators: { required: 'Severity is required' }, + }, + ], + confirmText: 'Select the severity to set for this incident.', + }, + ] // Define off-canvas details const offCanvas = { extendedInfoFields: [ - "Created", - "Updated", - "Tenant", - "Id", - "RedirectId", - "DisplayName", - "Status", - "Severity", - "AssignedTo", - "Classification", - "Determination", - "IncidentUrl", - "Tags", + 'Created', + 'Updated', + 'Tenant', + 'Id', + 'RedirectId', + 'DisplayName', + 'Status', + 'Severity', + 'AssignedTo', + 'Classification', + 'Determination', + 'IncidentUrl', + 'Tags', ], actions: actions, - }; + } // Simplified columns for the table const simpleColumns = [ - "Created", - "Tenant", - "Id", - "DisplayName", - "Status", - "Severity", - "Tags", - "IncidentUrl", - ]; + 'Created', + 'Tenant', + 'Id', + 'DisplayName', + 'Status', + 'Severity', + 'AssignedTo', + 'Tags', + 'IncidentUrl', + ] return ( { queryKey={`ExecIncidentsList-${userSettingsDefaults.currentTenant}-${startDate}-${endDate}`} apiData={{ StartDate: startDate, EndDate: endDate }} /> - ); -}; + ) +} -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page} -export default Page; +export default Page diff --git a/src/pages/security/reports/cve-report/index.js b/src/pages/security/reports/cve-report/index.js new file mode 100644 index 000000000000..b0d8317374d8 --- /dev/null +++ b/src/pages/security/reports/cve-report/index.js @@ -0,0 +1,30 @@ +import { Layout as DashboardLayout } from "../../../../layouts/index.js"; +import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; + +const Page = () => { + return ( + + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; diff --git a/src/pages/teams-share/onedrive/index.js b/src/pages/teams-share/onedrive/index.js index 7c06e88f1441..2eb84bd942c1 100644 --- a/src/pages/teams-share/onedrive/index.js +++ b/src/pages/teams-share/onedrive/index.js @@ -74,7 +74,16 @@ const Page = () => { multiple: false, creatable: false, api: { - url: '/api/listUsers', + url: '/api/ListGraphRequest', + dataKey: 'Results', + data: { + Endpoint: 'users', + manualPagination: true, + $select: 'id,userPrincipalName,displayName', + $count: true, + $orderby: 'displayName', + $top: 999, + }, labelField: (onedriveAccessUser) => `${onedriveAccessUser.displayName} (${onedriveAccessUser.userPrincipalName})`, valueField: 'userPrincipalName', diff --git a/src/pages/teams-share/sharepoint/index.js b/src/pages/teams-share/sharepoint/index.js index 42f08fdc0486..2c8ae199ee97 100644 --- a/src/pages/teams-share/sharepoint/index.js +++ b/src/pages/teams-share/sharepoint/index.js @@ -1,6 +1,6 @@ import { Layout as DashboardLayout } from '../../../layouts/index.js' import { CippTablePage } from '../../../components/CippComponents/CippTablePage.jsx' -import { Button } from '@mui/material' +import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material' import { Add, AddToPhotos, @@ -10,6 +10,7 @@ import { NoAccounts, Delete, CleaningServices, + Assessment, } from '@mui/icons-material' import Link from 'next/link' import { Stack } from '@mui/system' @@ -18,6 +19,110 @@ import { useSettings } from '../../../hooks/use-settings' import { useCippReportDB } from '../../../components/CippComponents/CippReportDBControls' import CippFormComponent from '../../../components/CippComponents/CippFormComponent' import { CippFormCondition } from '../../../components/CippComponents/CippFormCondition' +import { CippPropertyList } from '../../../components/CippComponents/CippPropertyList' +import { ApiGetCall } from '../../../api/ApiCall' + +// Friendly labels for the SharePoint version cleanup (trim) job progress fields. +const VERSION_CLEANUP_LABELS = { + Status: 'Status', + BatchDeleteMode: 'Cleanup Mode', + RequestTimeInUTC: 'Requested (UTC)', + LastProcessTimeInUTC: 'Last Processed (UTC)', + CompleteTimeInUTC: 'Completed (UTC)', + ListsProcessed: 'Lists Processed', + ListsUpdated: 'Lists Updated', + ListsFailed: 'Lists Failed', + FilesProcessed: 'Files Processed', + VersionsProcessed: 'Versions Processed', + VersionsDeleted: 'Versions Deleted', + VersionsFailed: 'Versions Failed', + StorageReleased: 'Storage Released (bytes)', + ErrorMessage: 'Error Message', + WorkItemId: 'Work Item ID', +} +// Order in which the fields are shown. +const VERSION_CLEANUP_FIELDS = Object.keys(VERSION_CLEANUP_LABELS) + +// Renders the body of the status modal based on the fetched job progress. +const VersionCleanupStatusBody = ({ statusApi }) => { + const progress = statusApi.data?.Results + + if (statusApi.isError) { + return Failed to load cleanup job status. + } + + // No job: either an empty/blank response, or the API's explicit "NoRequestFound" status. + if ( + !statusApi.isFetching && + (progress === undefined || + progress === null || + (typeof progress === 'string' && progress.trim() === '') || + progress?.Status === 'NoRequestFound') + ) { + return No cleanup job found for this site. + } + + // Backend couldn't parse the payload and returned the raw string. + if (!statusApi.isFetching && typeof progress === 'string') { + return {progress} + } + + const propertyItems = VERSION_CLEANUP_FIELDS.filter( + (key) => progress?.[key] !== undefined && progress?.[key] !== '', + ).map((key) => ({ + label: VERSION_CLEANUP_LABELS[key], + value: String(progress[key]), + })) + + return ( + ({ label: VERSION_CLEANUP_LABELS[key], value: '' })) + } + /> + ) +} + +// Custom-component action modal: opens directly (no confirmation step) and fetches the trim +// job status for the selected site, rendering it as a property list. +const VersionCleanupStatusModal = ({ row, tenantFilter, drawerVisible, setDrawerVisible }) => { + const siteRow = Array.isArray(row) ? row[0] : row + const siteUrl = siteRow?.webUrl + const statusApi = ApiGetCall({ + url: '/api/ListSPOVersionCleanup', + data: { + tenantFilter: siteRow?.Tenant ?? tenantFilter, + SiteUrl: siteUrl, + }, + queryKey: `SPOVersionCleanupStatus-${siteUrl}`, + waiting: !!drawerVisible && !!siteUrl, + }) + + return ( + setDrawerVisible(false)} + > + + Cleanup Job Status{siteRow?.displayName ? ` — ${siteRow.displayName}` : ''} + + + + + + + + + ) +} const Page = () => { const pageTitle = 'SharePoint Sites' @@ -242,7 +347,10 @@ const Page = () => { name="DeleteOlderThanDays" label="Delete Versions Older Than (days)" formControl={formHook} - validators={{ required: 'Please enter the number of days' }} + validators={{ + required: 'Please enter the number of days', + min: { value: 30, message: 'SharePoint requires at least 30 days' }, + }} /> { formControl={formHook} validators={{ required: 'Please enter the version limit' }} /> + ), defaultvalues: { BatchDeleteMode: '2', }, - customDataformatter: (row, action, formData) => ({ - tenantFilter: row.Tenant ?? tenantFilter, - SiteUrl: row.webUrl, - BatchDeleteMode: parseInt(formData.BatchDeleteMode, 10), - DeleteOlderThanDays: - formData.BatchDeleteMode === '0' ? parseInt(formData.DeleteOlderThanDays, 10) : -1, - MajorVersionLimit: - formData.BatchDeleteMode === '1' ? parseInt(formData.MajorVersionLimit, 10) : -1, - }), + customDataformatter: (row, action, formData) => { + const formatRow = (singleRow) => ({ + tenantFilter: singleRow.Tenant ?? tenantFilter, + SiteUrl: singleRow.webUrl, + BatchDeleteMode: parseInt(formData.BatchDeleteMode, 10), + DeleteOlderThanDays: + formData.BatchDeleteMode === '0' ? parseInt(formData.DeleteOlderThanDays, 10) : -1, + MajorVersionLimit: + formData.BatchDeleteMode === '1' ? parseInt(formData.MajorVersionLimit, 10) : -1, + MajorWithMinorVersionsLimit: + formData.BatchDeleteMode === '1' + ? parseInt(formData.MajorWithMinorVersionsLimit, 10) + : -1, + }) + // When multiple rows are selected, row is an array. Returning an array + // makes CippApiDialog send one request per row (bulk request mode). + return Array.isArray(row) ? row.map(formatRow) : formatRow(row) + }, + multiPost: false, + }, + { + label: 'Check Cleanup Job Status', + icon: , + customComponent: (row, { drawerVisible, setDrawerVisible }) => ( + + ), multiPost: false, }, ] diff --git a/src/pages/teams-share/teams/business-voice/index.js b/src/pages/teams-share/teams/business-voice/index.js index 3c9354706147..89f2d298ac76 100644 --- a/src/pages/teams-share/teams/business-voice/index.js +++ b/src/pages/teams-share/teams/business-voice/index.js @@ -35,7 +35,16 @@ const Page = () => { multiple: false, creatable: false, api: { - url: "/api/listUsers", + url: "/api/ListGraphRequest", + dataKey: "Results", + data: { + Endpoint: "users", + manualPagination: true, + $select: "id,userPrincipalName,displayName", + $count: true, + $orderby: "displayName", + $top: 999, + }, labelField: (input) => `${input.displayName} (${input.userPrincipalName})`, valueField: "userPrincipalName", }, diff --git a/src/pages/tenant/administration/alert-configuration/alert.jsx b/src/pages/tenant/administration/alert-configuration/alert.jsx index 7829d9df639e..0bb5f3a97b1d 100644 --- a/src/pages/tenant/administration/alert-configuration/alert.jsx +++ b/src/pages/tenant/administration/alert-configuration/alert.jsx @@ -51,6 +51,34 @@ const AlertWizard = () => { data: { tenantFilter }, waiting: !!tenantFilter, }) + + // Fetch the HaloPSA integration config so the PSA Ticket Strategy dropdown can show which + // option is the current integration default. + const integrationsConfig = ApiGetCall({ + url: '/api/ListExtensionsConfig', + queryKey: 'Integrations', + refetchOnMount: false, + refetchOnReconnect: false, + }) + const haloDefaultStrategy = integrationsConfig?.data?.HaloPSA?.LinkTicketsToUsers + ? 'split' + : 'consolidated' + const psaStrategyDropdownOptions = [ + { + value: 'split', + label: + haloDefaultStrategy === 'split' + ? 'One ticket per affected user (HaloPSA integration default)' + : 'One ticket per affected user', + }, + { + value: 'consolidated', + label: + haloDefaultStrategy === 'consolidated' + ? 'One consolidated ticket per tenant (HaloPSA integration default)' + : 'One consolidated ticket per tenant', + }, + ] const [recurrenceOptions, setRecurrenceOptions] = useState([ { value: '30m', label: 'Every 30 minutes' }, { value: '1h', label: 'Every hour' }, @@ -203,6 +231,13 @@ const AlertWizard = () => { const desiredStartEpoch = parseInt(alert.RawAlert.DesiredStartTime) startDateTimeForForm = desiredStartEpoch } + // Resolve the stored strategy ('split' / 'consolidated' / '' for legacy/inherit) to the + // matching dynamic option. When empty, fall back to the current integration default so + // the dropdown always shows a meaningful selection. + const storedStrategy = alert.RawAlert.PsaTicketStrategy || haloDefaultStrategy + const psaStrategyValue = + psaStrategyDropdownOptions.find((opt) => opt.value === storedStrategy) || + psaStrategyDropdownOptions[0] const resetObject = { tenantFilter: tenantFilterForForm, excludedTenants: excludedTenantsFormatted, @@ -212,6 +247,7 @@ const AlertWizard = () => { startDateTime: startDateTimeForForm, CustomSubject: alert.RawAlert.CustomSubject || '', AlertComment: alert.RawAlert.AlertComment || '', + PsaTicketStrategy: psaStrategyValue, } if (usedCommand?.requiresInput && alert.RawAlert.Parameters) { try { @@ -519,6 +555,7 @@ const AlertWizard = () => { PostExecution: values.postExecution, AlertComment: values.AlertComment, CustomSubject: values.CustomSubject, + PsaTicketStrategy: values.PsaTicketStrategy?.value ?? values.PsaTicketStrategy ?? '', } apiRequest.mutate( { url: '/api/AddScriptedAlert', data: postObject }, @@ -612,23 +649,17 @@ const AlertWizard = () => { }} /> - - - - - + + + @@ -928,23 +959,17 @@ const AlertWizard = () => { }} /> - - - - - + + + @@ -1095,6 +1120,27 @@ const AlertWizard = () => { options={postExecutionOptions} /> + + + + + + + + new Date(ms).toLocaleString([], { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); + +const Page = () => { + const theme = useTheme(); + const tenant = useSettings().currentTenant; + const [dateParams, setDateParams] = useState({ RelativeTime: "48h" }); + const [tableFilterTenant, setTableFilterTenant] = useState(null); + + const dateApiData = useMemo( + () => ({ + ...(dateParams.RelativeTime ? { RelativeTime: dateParams.RelativeTime } : {}), + ...(dateParams.StartDate ? { StartDate: dateParams.StartDate } : {}), + ...(dateParams.EndDate ? { EndDate: dateParams.EndDate } : {}), + }), + [dateParams] + ); + const periodKey = `${dateParams.RelativeTime ?? ""}-${dateParams.StartDate ?? ""}-${ + dateParams.EndDate ?? "" + }`; + + // Charts/KPIs/heatmap share one fetch; the detail table fetches separately. Both honour the selector. + const statsQuery = ApiGetCall({ + url: "/api/ListAuditLogCoverage", + data: { tenantFilter: tenant, ...dateApiData }, + queryKey: `AuditLogCoverageStats-${tenant}-${periodKey}`, + waiting: !!tenant, + }); + + const rows = useMemo(() => { + const d = statsQuery.data; + if (Array.isArray(d)) return d; + if (Array.isArray(d?.Results)) return d.Results; + return []; + }, [statsQuery.data]); + + const stats = useMemo(() => summarize(rows), [rows]); + + const kpis = useMemo(() => { + const pct = stats.total ? Math.round((stats.processed / stats.total) * 100) : 0; + const attention = stats.deadletter + stats.skipped + stats.gaps; + return [ + { + icon: , + data: `${pct}%`, + name: "Processed", + color: pct >= 95 ? "success" : "warning", + toolTip: `${stats.processed} of ${stats.total} windows · ${stats.totalRecords} records, ${stats.matched} matched`, + }, + { + icon: , + data: stats.medianLatency != null ? `${Math.round(stats.medianLatency)}m` : "—", + name: "Median latency", + color: "secondary", + toolTip: "Median window close → processed (regular windows)", + }, + { + icon: , + data: attention, + name: "Needs attention", + color: attention > 0 ? "error" : "success", + toolTip: `${stats.deadletter} dead-lettered · ${stats.skipped} skipped · ${stats.gaps} gaps`, + }, + { + icon: , + data: stats.throttleEvents, + name: "Throttle / retries", + color: stats.throttleEvents > 0 || stats.retriedWindows > 0 ? "warning" : "secondary", + toolTip: `${stats.throttleEvents} throttle defers · ${stats.retriedWindows} windows retried`, + }, + ]; + }, [stats]); + + // Window status over time: stacked bar with a custom tooltip that names the tenant(s) behind any + // error/retry/dead-letter segment (regular windows only; reconciliation excluded from the trend). + const statusChart = useMemo(() => { + const b = buildBuckets(rows); + if (!b) return null; + const keys = ["Processed", "InFlight", "Retrying", "DeadLetter"]; + const colorMap = { + Processed: theme.palette.success.main, + InFlight: theme.palette.info?.main || theme.palette.primary.main, + Retrying: theme.palette.warning.main, + DeadLetter: theme.palette.error.main, + }; + const counts = keys.map(() => new Array(b.count).fill(0)); + const tenantsByBucket = Array.from({ length: b.count }, () => ({})); + for (const r of rows) { + if (r.Type === "Reconciliation" || r.Type === "Manual") continue; + const i = bucketIndexOf(toMs(r.WindowStart), b); + if (i < 0) continue; + const st = classifyRow(r); + const ki = keys.indexOf(st); + if (ki < 0) continue; + counts[ki][i] += 1; + if (st !== "Processed") { + const lbl = STATE_LABELS[st]; + const t = (r.Tenant || r.TenantId || "?").replace(/\.onmicrosoft\.com$/, ""); + tenantsByBucket[i][lbl] = tenantsByBucket[i][lbl] || {}; + tenantsByBucket[i][lbl][t] = (tenantsByBucket[i][lbl][t] || 0) + 1; + } + } + const labels = Array.from({ length: b.count }, (_, i) => bucketLabel(bucketStartMs(i, b))); + const colors = keys.map((k) => colorMap[k]); + const bg = theme.palette.background.paper; + const fg = theme.palette.text.primary; + const sub = theme.palette.text.secondary; + const series = keys.map((k, ki) => ({ name: STATE_LABELS[k], data: counts[ki] })); + const options = { + chart: { type: "bar", stacked: true, background: "transparent", toolbar: { show: false } }, + theme: { mode: theme.palette.mode }, + colors, + plotOptions: { bar: { columnWidth: "72%" } }, + dataLabels: { enabled: false }, + xaxis: { + categories: labels, + tickAmount: Math.min(12, b.count), + labels: { rotate: -45, hideOverlappingLabels: true, style: { fontSize: "10px" } }, + }, + yaxis: { min: 0, forceNiceScale: true, labels: { formatter: (v) => Math.round(v) } }, + legend: { show: true, position: "top" }, + grid: { borderColor: theme.palette.divider }, + tooltip: { + custom: ({ dataPointIndex, w }) => { + const cat = w.globals.labels[dataPointIndex]; + let html = `
    `; + html += `
    ${cat}
    `; + keys.forEach((k, ki) => { + const v = counts[ki][dataPointIndex]; + if (!v) return; + const lbl = STATE_LABELS[k]; + html += `
    ${lbl}: ${v}
    `; + const tn = tenantsByBucket[dataPointIndex][lbl]; + if (tn) { + const parts = Object.entries(tn) + .map(([t, c]) => `${t} (${c})`) + .join(", "); + html += `
    ${parts}
    `; + } + }); + html += `
    `; + return html; + }, + }, + }; + return { series, options }; + }, [rows, theme]); + + // Latency stage breakdown + audit volume over the same buckets. + const trendCharts = useMemo(() => { + const b = buildBuckets(rows); + if (!b) return null; + const sums = [0, 1, 2].map(() => new Array(b.count).fill(0)); + const cnts = new Array(b.count).fill(0); + const recSum = new Array(b.count).fill(0); + const matSum = new Array(b.count).fill(0); + for (const r of rows) { + if (r.Type === "Reconciliation" || r.Type === "Manual") continue; + const i = bucketIndexOf(toMs(r.WindowStart), b); + if (i < 0) continue; + recSum[i] += Number(r.RecordCount) || 0; + matSum[i] += Number(r.MatchedCount) || 0; + if (r.State === "Processed") { + const l = latencyMinutes(r); + if (l.total != null && l.total >= 0) { + sums[0][i] += Math.max(0, l.create || 0); + sums[1][i] += Math.max(0, l.download || 0); + sums[2][i] += Math.max(0, l.process || 0); + cnts[i] += 1; + } + } + } + const labels = Array.from({ length: b.count }, (_, i) => bucketLabel(bucketStartMs(i, b))); + const avg = (si) => sums[si].map((s, i) => (cnts[i] ? Math.round(s / cnts[i]) : null)); + const axis = { + xaxis: { + categories: labels, + tickAmount: Math.min(10, b.count), + labels: { rotate: -45, hideOverlappingLabels: true, style: { fontSize: "10px" } }, + }, + yaxis: { min: 0, forceNiceScale: true, labels: { formatter: (v) => Math.round(v) } }, + legend: { show: true, position: "top" }, + grid: { borderColor: theme.palette.divider }, + dataLabels: { enabled: false }, + theme: { mode: theme.palette.mode }, + }; + return { + latSeries: [ + { name: "Create lag", data: avg(0) }, + { name: "Download lag", data: avg(1) }, + { name: "Process lag", data: avg(2) }, + ], + latOptions: { + ...axis, + chart: { type: "area", stacked: true, background: "transparent", toolbar: { show: false }, zoom: { enabled: false } }, + colors: [theme.palette.warning.main, theme.palette.info?.main || theme.palette.primary.main, theme.palette.success.main], + stroke: { curve: "smooth", width: 2 }, + fill: { type: "solid", opacity: 0.25 }, + tooltip: { theme: theme.palette.mode, y: { formatter: (v) => (v == null ? "—" : `${Math.round(v)} min`) } }, + }, + volSeries: [ + { name: "Records", data: recSum }, + { name: "Matched", data: matSum }, + ], + volOptions: { + ...axis, + chart: { type: "line", background: "transparent", toolbar: { show: false }, zoom: { enabled: false } }, + colors: [theme.palette.primary.main, theme.palette.error.main], + stroke: { curve: "smooth", width: 2 }, + markers: { size: 0, hover: { size: 4 } }, + tooltip: { theme: theme.palette.mode }, + }, + }; + }, [rows, theme]); + + // Triage feed: windows that errored, retried, throttled, were skipped or dead-lettered. Newest first. + const problems = useMemo(() => { + const num = (v) => Number(v) || 0; + const list = rows.filter( + (r) => + r.State === "DeadLetter" || + r.State === "Skipped" || + num(r.RetryCount) > 0 || + num(r.ThrottleCount) > 0 + ); + list.sort((a, b) => { + const ta = toMs(a.LastErrorUtc) || toMs(a.WindowStart) || 0; + const tb = toMs(b.LastErrorUtc) || toMs(b.WindowStart) || 0; + return tb - ta; + }); + return list.slice(0, 12); + }, [rows]); + + const offCanvas = { + children: (row) => , + size: "lg", + }; + + const tableFilters = tableFilterTenant ? [{ id: "Tenant", value: tableFilterTenant }] : []; + + const ChartCard = ({ title, subheader, ready, children }) => ( + + + + + {statsQuery.isFetching ? ( + + ) : !ready ? ( + + No search windows in this period. + + ) : ( + children + )} + + + ); + + return ( + <> + + + + + + + + + + {statusChart && ( + + )} + + + + + + {trendCharts && ( + + )} + + + + + {trendCharts && ( + + )} + + + + + + + + + {statsQuery.isFetching ? ( + + ) : ( + setTableFilterTenant(t)} + /> + )} + + + + + + + + {statsQuery.isFetching ? ( + + ) : problems.length === 0 ? ( + + No problems in this period — everything processed cleanly. + + ) : ( + }> + {problems.map((r, i) => { + const num = (v) => Number(v) || 0; + const badge = + r.State === "DeadLetter" + ? { label: "Dead-letter", color: "error" } + : r.State === "Skipped" + ? { label: "Skipped", color: "default" } + : num(r.ThrottleCount) > 0 + ? { label: `Throttle ×${num(r.ThrottleCount)}`, color: "warning" } + : { label: `Retry ×${num(r.RetryCount)}`, color: "warning" }; + const when = r.LastErrorUtc || r.ProcessedUtc || r.WindowStart; + return ( + + + {String(r.Tenant || "").replace(/\.onmicrosoft\.com$/, "")} + + + + {r.LastError || "—"} + + + {when ? new Date(when).toLocaleString() : "—"} + + + ); + })} + + )} + + + + + + {tableFilterTenant && ( + + + Filtered to {tableFilterTenant.replace(/\.onmicrosoft\.com$/, "")} + + + + )} + + + + + + + ); +}; + +Page.getLayout = (page) => ( + + {page} + +); + +export default Page; diff --git a/src/pages/tenant/administration/audit-logs/index.js b/src/pages/tenant/administration/audit-logs/index.js index c04388b8a3cc..efe3f31477a4 100644 --- a/src/pages/tenant/administration/audit-logs/index.js +++ b/src/pages/tenant/administration/audit-logs/index.js @@ -1,21 +1,9 @@ import { useState } from "react"; -import { useRouter } from "next/router"; import { Layout as DashboardLayout } from "../../../../layouts/index.js"; import { TabbedLayout } from "../../../../layouts/TabbedLayout"; import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; -import { - Box, - Button, - Accordion, - AccordionSummary, - AccordionDetails, - Typography, -} from "@mui/material"; -import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; -import { useForm } from "react-hook-form"; -import CippFormComponent from "../../../../components/CippComponents/CippFormComponent"; +import { CippDateRangeFilter } from "../../../../components/CippComponents/CippDateRangeFilter"; import { EyeIcon } from "@heroicons/react/24/outline"; -import { Grid } from "@mui/system"; import tabOptions from "./tabOptions.json"; // Saved Logs Configuration @@ -31,131 +19,24 @@ const savedLogsActions = [ ]; const Page = () => { - const router = useRouter(); + // Preserves the previous behaviour: RelativeTime defaults to "7d" and is always sent. + const [apiParams, setApiParams] = useState({ RelativeTime: "7d" }); - const formControl = useForm({ - mode: "onChange", - defaultValues: { - dateFilter: "relative", - Time: 7, - Interval: { label: "Days", value: "d" }, - }, - }); - - const [expanded, setExpanded] = useState(false); - const [relativeTime, setRelativeTime] = useState("7d"); - const [startDate, setStartDate] = useState(null); - const [endDate, setEndDate] = useState(null); - - const onSubmit = (data) => { - if (data.dateFilter === "relative") { - setRelativeTime(`${data.Time}${data.Interval.value}`); - setStartDate(null); - setEndDate(null); - } else if (data.dateFilter === "startEnd") { - setRelativeTime(null); - setStartDate(data.startDate); - setEndDate(data.endDate); - } - }; - - // API parameters for saved logs - const apiParams = { - RelativeTime: relativeTime ? relativeTime : "7d", - ...(startDate && { StartDate: startDate }), - ...(endDate && { EndDate: endDate }), + const handleApply = ({ RelativeTime, StartDate, EndDate }) => { + setApiParams({ + RelativeTime: RelativeTime ? RelativeTime : "7d", + ...(StartDate && { StartDate }), + ...(EndDate && { EndDate }), + }); }; const searchFilter = ( - setExpanded(!expanded)}> - }> - Search Options - - -
    - - {/* Date Filter Type */} - - - - - {/* Relative Time Filter */} - {formControl.watch("dateFilter") === "relative" && ( - <> - - - - - - - - - - - - )} - - {/* Start and End Date Filters */} - {formControl.watch("dateFilter") === "startEnd" && ( - <> - - - - - - - - )} - - {/* Submit Button */} - - - - -
    -
    -
    + ); return ( @@ -165,22 +46,15 @@ const Page = () => { apiUrl={savedLogsApiUrl} apiDataKey="Results" simpleColumns={savedLogsColumns} - queryKey={`SavedLogs-${relativeTime}-${startDate}-${endDate}`} + queryKey={`SavedLogs-${apiParams.RelativeTime ?? ""}-${apiParams.StartDate ?? ""}-${ + apiParams.EndDate ?? "" + }`} apiData={apiParams} actions={savedLogsActions} /> ); }; -/* Comment to Developer: - - This page displays saved audit logs with date filtering options. - - The filter options are implemented within an Accordion for a collapsible UI. - - DateFilter types are supported as 'Relative' and 'Start/End'. - - Relative time is calculated based on Time and Interval inputs. - - Form state is managed using react-hook-form for simplicity and reusability. - - Filters are dynamically applied to the table query. -*/ - Page.getLayout = (page) => ( {page} diff --git a/src/pages/tenant/administration/audit-logs/manual-searches.js b/src/pages/tenant/administration/audit-logs/manual-searches.js new file mode 100644 index 000000000000..e6fc472ce5f2 --- /dev/null +++ b/src/pages/tenant/administration/audit-logs/manual-searches.js @@ -0,0 +1,61 @@ +import { Layout as DashboardLayout } from "../../../../layouts/index.js"; +import { TabbedLayout } from "../../../../layouts/TabbedLayout"; +import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; +import { CippAuditLogSearchDrawer } from "../../../../components/CippComponents/CippAuditLogSearchDrawer.jsx"; +import { EyeIcon } from "@heroicons/react/24/outline"; +import { ManageSearch } from "@mui/icons-material"; +import tabOptions from "./tabOptions.json"; +import { useSettings } from "../../../../hooks/use-settings"; + +const simpleColumns = ["displayName", "status", "filterStartDateTime", "filterEndDateTime"]; + +const apiUrl = "/api/ListAuditLogSearches?Type=Searches"; +const pageTitle = "Manual Searches"; + +const actions = [ + { + label: "View Results", + link: "/tenant/administration/audit-logs/search-results?id=[id]&name=[displayName]", + color: "primary", + icon: , + }, + { + label: "Process Logs", + url: "/api/ExecAuditLogSearch", + confirmText: + "Process these logs? Note: This will only alert on logs that match your Alert Configuration rules.", + type: "POST", + data: { + Action: "ProcessLogs", + SearchId: "id", + }, + icon: , + }, +]; + +const Page = () => { + const currentTenant = useSettings().currentTenant; + const queryKey = `AuditLogSearches-${currentTenant}`; + + return ( + <> + } + /> + + ); +}; + +Page.getLayout = (page) => ( + + {page} + +); + +export default Page; diff --git a/src/pages/tenant/administration/audit-logs/searches.js b/src/pages/tenant/administration/audit-logs/searches.js index ffa70e4ab681..0067272ee402 100644 --- a/src/pages/tenant/administration/audit-logs/searches.js +++ b/src/pages/tenant/administration/audit-logs/searches.js @@ -1,54 +1,100 @@ +import { useMemo, useState } from "react"; +import { Chip, Stack } from "@mui/material"; import { Layout as DashboardLayout } from "../../../../layouts/index.js"; import { TabbedLayout } from "../../../../layouts/TabbedLayout"; import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; -import { CippAuditLogSearchDrawer } from "../../../../components/CippComponents/CippAuditLogSearchDrawer.jsx"; -import { EyeIcon } from "@heroicons/react/24/outline"; -import { ManageSearch } from "@mui/icons-material"; +import { CippDateRangeFilter } from "../../../../components/CippComponents/CippDateRangeFilter"; import tabOptions from "./tabOptions.json"; import { useSettings } from "../../../../hooks/use-settings"; +import { ApiGetCall } from "../../../../api/ApiCall"; -const simpleColumns = ["displayName", "status", "filterStartDateTime", "filterEndDateTime"]; - -const apiUrl = "/api/ListAuditLogSearches?Type=Searches"; -const pageTitle = "Log Searches"; - -const actions = [ - { - label: "View Results", - link: "/tenant/administration/audit-logs/search-results?id=[id]&name=[displayName]", - color: "primary", - icon: , - }, - { - label: "Process Logs", - url: "/api/ExecAuditLogSearch", - confirmText: - "Process these logs? Note: This will only alert on logs that match your Alert Configuration rules.", - type: "POST", - data: { - Action: "ProcessLogs", - SearchId: "id", - }, - icon: , - }, +// Log Searches lists the V2 audit-log coverage ledger - one row per search window the pipeline runs. +// The full diagnostic dashboard lives on the advanced "Search Coverage" tab; this is the everyday view. +const simpleColumns = [ + "Tenant", + "Type", + "WindowStart", + "WindowEnd", + "State", + "SearchStatus", + "RecordCount", + "MatchedCount", + "LastError", ]; +const apiUrl = "/api/ListAuditLogCoverage"; const Page = () => { - const currentTenant = useSettings().currentTenant; - const queryKey = `AuditLogSearches-${currentTenant}`; + const tenant = useSettings().currentTenant; + const [dateParams, setDateParams] = useState({ RelativeTime: "48h" }); - return ( - <> - } + const dateApiData = useMemo( + () => ({ + ...(dateParams.RelativeTime ? { RelativeTime: dateParams.RelativeTime } : {}), + ...(dateParams.StartDate ? { StartDate: dateParams.StartDate } : {}), + ...(dateParams.EndDate ? { EndDate: dateParams.EndDate } : {}), + }), + [dateParams] + ); + const periodKey = `${dateParams.RelativeTime ?? ""}-${dateParams.StartDate ?? ""}-${ + dateParams.EndDate ?? "" + }`; + + // Small health summary (own fetch; the table fetches separately). Both honour the tenant selector. + const statsQuery = ApiGetCall({ + url: apiUrl, + data: { tenantFilter: tenant, ...dateApiData }, + queryKey: `LogSearchHealth-${tenant}-${periodKey}`, + waiting: !!tenant, + }); + + const health = useMemo(() => { + const d = statsQuery.data; + const rows = Array.isArray(d) ? d : Array.isArray(d?.Results) ? d.Results : []; + const searching = rows.filter((r) => r.State === "Planned" || r.State === "Created").length; + const failed = rows.filter((r) => r.State === "DeadLetter").length; + const skipped = rows.filter((r) => r.State === "Skipped").length; + return { total: rows.length, searching, failed, skipped }; + }, [statsQuery.data]); + + const tableFilter = ( + + - + {!statsQuery.isFetching && health.total > 0 && ( + + {health.failed === 0 ? ( + + ) : ( + + )} + + {health.skipped > 0 && ( + + )} + + )} + + ); + + return ( + ); }; diff --git a/src/pages/tenant/administration/audit-logs/tabOptions.json b/src/pages/tenant/administration/audit-logs/tabOptions.json index 9c5bf289488d..d570e2230bfa 100644 --- a/src/pages/tenant/administration/audit-logs/tabOptions.json +++ b/src/pages/tenant/administration/audit-logs/tabOptions.json @@ -9,6 +9,17 @@ "path": "/tenant/administration/audit-logs/searches", "icon": "List" }, + { + "label": "Manual Searches", + "path": "/tenant/administration/audit-logs/manual-searches", + "icon": "ManageSearch" + }, + { + "label": "Search Coverage", + "path": "/tenant/administration/audit-logs/coverage", + "icon": "Timeline", + "advanced": true + }, { "label": "Directory Audits", "path": "/tenant/administration/audit-logs/directory-audits", diff --git a/src/pages/tenant/administration/authentication-methods/index.js b/src/pages/tenant/administration/authentication-methods/index.js index 2ceecbac8d62..f865d6a0172b 100644 --- a/src/pages/tenant/administration/authentication-methods/index.js +++ b/src/pages/tenant/administration/authentication-methods/index.js @@ -1,6 +1,8 @@ import { Layout as DashboardLayout } from "../../../../layouts/index.js"; import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; -import { Check, Block } from "@mui/icons-material"; +import CippFormComponent from "../../../../components/CippComponents/CippFormComponent.jsx"; +import { Box } from "@mui/material"; +import { Check, Block, Settings, Public } from "@mui/icons-material"; import { UserGroupIcon } from "@heroicons/react/24/outline"; import { useSettings } from "../../../../hooks/use-settings.js"; @@ -12,6 +14,221 @@ const Page = () => { // Columns configuration based on provided structure const simpleColumns = ["id", "state", "includeTargets", "excludeTargets"]; + // Tri-state dropdown shared by several Microsoft feature settings + const tristateOptions = [ + { label: "Default", value: "default" }, + { label: "Enabled", value: "enabled" }, + { label: "Disabled", value: "disabled" }, + ]; + + // Group picker reused by "Deploy to Custom Group" and the Email exclude-groups field + const groupFieldApi = { + url: "/api/ListGraphRequest", + dataKey: "Results", + queryKey: `ListAuthenticationPolicyGroups-${tenant}`, + labelField: (group) => (group.id ? `${group.displayName} (${group.id})` : group.displayName), + valueField: "id", + addedField: { + description: "description", + }, + data: { + Endpoint: "groups", + manualPagination: true, + $select: "id,displayName,description", + $orderby: "displayName", + $top: 999, + $count: true, + }, + }; + + // Coerce a number field (registered as a string) to a Number, dropping empties + const num = (v) => (v === "" || v === null || v === undefined ? undefined : Number(v)); + + // Match a method row by id, case-insensitively (Graph casing varies) + const isId = (row, id) => row?.id?.toLowerCase() === id.toLowerCase(); + + // Methods that expose configurable sub-settings (others only support enable/disable + targeting) + const configurableMethods = [ + "TemporaryAccessPass", + "MicrosoftAuthenticator", + "Email", + "QRCodePin", + "Fido2", + "Voice", + "SMS", + ]; + + // Per-method field definitions for the "Configure" action. Keyed by method id so the + // right set can be looked up directly from the row, instead of relying on dialog-level + // per-field conditions. rowDefaultPath is page-local metadata (used by defaultvalues + // below) and is stripped before the fields reach CippFormComponent. + const methodFieldDefs = { + TemporaryAccessPass: [ + { + type: "switch", + name: "TAPisUsableOnce", + label: "One-time use only", + rowDefaultPath: "isUsableOnce", + }, + { + type: "number", + name: "TAPMinimumLifetime", + label: "Minimum lifetime (minutes)", + rowDefaultPath: "minimumLifetimeInMinutes", + }, + { + type: "number", + name: "TAPMaximumLifetime", + label: "Maximum lifetime (minutes)", + rowDefaultPath: "maximumLifetimeInMinutes", + }, + { + type: "number", + name: "TAPDefaultLifeTime", + label: "Default lifetime (minutes)", + rowDefaultPath: "defaultLifetimeInMinutes", + }, + { + type: "number", + name: "TAPDefaultLength", + label: "Default length (characters)", + rowDefaultPath: "defaultLength", + }, + ], + MicrosoftAuthenticator: [ + { + type: "switch", + name: "MicrosoftAuthenticatorSoftwareOathEnabled", + label: "Allow software OATH tokens", + rowDefaultPath: "isSoftwareOathEnabled", + }, + { + type: "select", + name: "MicrosoftAuthenticatorDisplayAppInfo", + label: "Show application name in push and passwordless notifications", + creatable: false, + options: tristateOptions, + rowDefaultPath: "featureSettings.displayAppInformationRequiredState.state", + }, + { + type: "select", + name: "MicrosoftAuthenticatorDisplayLocation", + label: "Show geographic location in push and passwordless notifications", + creatable: false, + options: tristateOptions, + rowDefaultPath: "featureSettings.displayLocationInformationRequiredState.state", + }, + { + type: "select", + name: "MicrosoftAuthenticatorCompanionApp", + label: "Companion app allowed state", + creatable: false, + options: tristateOptions, + rowDefaultPath: "featureSettings.companionAppAllowedState.state", + }, + ], + Email: [ + { + type: "select", + name: "EmailAllowExternalIdToUseEmailOtp", + label: "Allow external users to use Email OTP", + creatable: false, + options: tristateOptions, + rowDefaultPath: "allowExternalIdToUseEmailOtp", + }, + { + type: "autoComplete", + name: "EmailExcludeGroupIds", + label: "Exclude group(s)", + multiple: true, + creatable: false, + rowDefaultPath: "excludeTargets", + api: groupFieldApi, + }, + ], + QRCodePin: [ + { + type: "number", + name: "QRCodeLifetimeInDays", + label: "Standard QR code lifetime (days, 1-395)", + rowDefaultPath: "standardQRCodeLifetimeInDays", + }, + { + type: "number", + name: "QRCodePinLength", + label: "PIN length (8-20)", + rowDefaultPath: "pinLength", + }, + ], + Fido2: [ + { + type: "switch", + name: "FIDO2AttestationEnforced", + label: "Enforce attestation", + rowDefaultPath: "isAttestationEnforced", + }, + { + type: "switch", + name: "FIDO2SelfServiceRegistration", + label: "Allow self-service registration", + rowDefaultPath: "isSelfServiceRegistrationAllowed", + }, + ], + Voice: [ + { + type: "switch", + name: "VoiceIsOfficePhoneAllowed", + label: "Allow office phone registration", + rowDefaultPath: "isOfficePhoneAllowed", + }, + ], + // SMS (isUsableForSignIn lives on each include-target) + SMS: [ + { + type: "switch", + name: "SmsIsUsableForSignIn", + label: "Use for sign-in", + rowDefaultPath: "includeTargets.0.isUsableForSignIn", + }, + ], + }; + + // Look up the field set for whichever configurable method this row is + const getMethodFields = (row) => { + const methodId = Object.keys(methodFieldDefs).find((id) => isId(row, id)); + return methodId ? methodFieldDefs[methodId] : []; + }; + + const getNested = (obj, path) => + path + .split(".") + .reduce((acc, key) => (acc && acc[key] !== undefined ? acc[key] : undefined), obj); + + // Build the POST body for the selected method by coercing each visible field by its type. + // Field names match the backend parameter names, so methodFieldDefs is the single source of truth. + const buildConfigBody = (row, formData) => { + const body = { + tenantFilter: tenant === "AllTenants" && row?.Tenant ? row.Tenant : tenant, + id: row?.id, + state: row?.state, + }; + getMethodFields(row).forEach((field) => { + const value = formData?.[field.name]; + if (field.type === "switch") { + body[field.name] = !!value; + } else if (field.type === "number") { + body[field.name] = num(value); + } else if (field.type === "autoComplete") { + body[field.name] = Array.isArray(value) + ? value.map((item) => item.value).filter(Boolean) + : []; + } else { + body[field.name] = value; // select / scalar — undefined is dropped by JSON + } + }); + return body; + }; + const actions = [ { label: "Enable Policy", @@ -46,32 +263,13 @@ const Page = () => { creatable: false, allowResubmit: true, validators: { required: "Please select at least one group" }, - api: { - url: "/api/ListGraphRequest", - dataKey: "Results", - queryKey: `ListAuthenticationPolicyGroups-${tenant}`, - labelField: (group) => - group.id ? `${group.displayName} (${group.id})` : group.displayName, - valueField: "id", - addedField: { - description: "description", - }, - data: { - Endpoint: "groups", - manualPagination: true, - $select: "id,displayName,description", - $orderby: "displayName", - $top: 999, - $count: true, - }, - }, + api: groupFieldApi, }, ], customDataformatter: (row, action, formData) => { const selectedGroups = Array.isArray(formData?.groupTargets) ? formData.groupTargets : []; - const tenantFilterValue = tenant === "AllTenants" && row?.Tenant ? row.Tenant : tenant; return { - tenantFilter: tenantFilterValue, + tenantFilter: tenant === "AllTenants" && row?.Tenant ? row.Tenant : tenant, state: row?.state, id: row?.id, GroupIds: selectedGroups.map((group) => group.value).filter(Boolean), @@ -79,6 +277,62 @@ const Page = () => { }, multiPost: false, }, + { + label: "Assign to All Users", + type: "POST", + icon: , + url: "/api/SetAuthMethod", + hideBulk: true, + confirmText: 'Are you sure you want to scope "[id]" to all users?', + customDataformatter: (row) => ({ + tenantFilter: tenant === "AllTenants" && row?.Tenant ? row.Tenant : tenant, + state: row?.state, + id: row?.id, + GroupIds: ["all_users"], + }), + multiPost: false, + }, + { + label: "Configure", + type: "POST", + icon: , + url: "/api/SetAuthMethod", + hideBulk: true, + condition: (row) => row?.state === "enabled" && configurableMethods.some((m) => isId(row, m)), + confirmText: "Configure authentication method settings.", + // Computed straight from the row via CippApiDialog's existing `defaultvalues` function + // prop — no dialog-level default-value handling needed for this action. + defaultvalues: (row) => { + const out = {}; + getMethodFields(row).forEach(({ name, type, rowDefaultPath }) => { + const val = getNested(row, rowDefaultPath); + if (type === "autoComplete") { + out[name] = (Array.isArray(val) ? val : []).map((v) => ({ + label: v?.id ?? v, + value: v?.id ?? v, + })); + } else if (type === "select") { + out[name] = tristateOptions.find((o) => o.value === val) ?? tristateOptions[0]; + } else { + out[name] = val; + } + }); + return out; + }, + // Rendered via CippApiDialog's existing `children` render-prop instead of `fields`, so + // the per-method field set can be picked by plain JS off `row` without any dialog changes. + children: ({ formHook, row }) => ( + <> + {getMethodFields(row).map(({ rowDefaultPath, ...fieldProps }, i) => ( + + + + ))} + + ), + customDataformatter: (row, action, formData) => buildConfigBody(row, formData), + multiPost: false, + }, ]; const offCanvas = { diff --git a/src/pages/tenant/administration/tenants/edit.js b/src/pages/tenant/administration/tenants/edit.js index 960e536859b1..d791f05f2b81 100644 --- a/src/pages/tenant/administration/tenants/edit.js +++ b/src/pages/tenant/administration/tenants/edit.js @@ -69,16 +69,18 @@ const Page = () => { RemoveMFADevices: false, RemoveTeamsPhoneDID: false, ClearImmutableId: false, + DisableOneDriveSharing: false, + removeCalendarPermissions: false, }; - + let offboardingDefaults = {}; - + if (tenantOffboardingDefaults) { try { const parsed = JSON.parse(tenantOffboardingDefaults); // Merge defaults with parsed values to ensure all fields are defined - offboardingDefaults = { - offboardingDefaults: { ...defaultOffboardingValues, ...parsed } + offboardingDefaults = { + offboardingDefaults: { ...defaultOffboardingValues, ...parsed } }; } catch { offboardingDefaults = { offboardingDefaults: defaultOffboardingValues }; @@ -86,7 +88,7 @@ const Page = () => { } else { offboardingDefaults = { offboardingDefaults: defaultOffboardingValues }; } - + offboardingFormControl.reset(offboardingDefaults); } }, [tenantDetails.isSuccess, tenantDetails.data, id]); @@ -113,8 +115,10 @@ const Page = () => { RemoveMFADevices: false, RemoveTeamsPhoneDID: false, ClearImmutableId: false, + DisableOneDriveSharing: false, + removeCalendarPermissions: false, }; - + offboardingFormControl.reset({ offboardingDefaults: defaultOffboardingValues }); }; @@ -222,7 +226,7 @@ const Page = () => { Configure default offboarding settings specifically for this tenant. These settings will override user defaults when offboarding users in this tenant. - + { }} hideTitle={true} > - - + -