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/Conventional_Commits.yml b/.github/workflows/Conventional_Commits.yml new file mode 100644 index 000000000000..6e026c8366ca --- /dev/null +++ b/.github/workflows/Conventional_Commits.yml @@ -0,0 +1,65 @@ +name: Conventional Commits Check + +on: + # Using pull_request_target instead of pull_request for secure handling of fork PRs + pull_request_target: + # Re-run on title edits so a corrected title clears the check + types: [opened, synchronize, reopened, edited] + # Only check PRs targeting the dev branch + branches: + - dev + +permissions: + pull-requests: write + issues: write + +jobs: + conventional-commits: + name: Validate Conventional Commits + runs-on: ubuntu-slim + steps: + - name: Validate PR title + uses: actions/github-script@v9 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const title = context.payload.pull_request.title || ''; + const pattern = /^(feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert)(\(.+\))?!?: .+/; + + if (pattern.test(title)) { + console.log(`โœ“ PR title follows Conventional Commits format: "${title}"`); + return; + } + + console.log(`โŒ PR title does not follow Conventional Commits format: "${title}"`); + + const message = [ + 'โŒ **This PR title does not follow the [Conventional Commits](https://www.conventionalcommits.org/) format.**', + '', + 'Expected format: `type(scope)?: description`', + '', + 'Allowed types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`, `ci`, `build`, `revert`', + '', + 'Examples:', + '- `feat: add tenant filter to the user table`', + '- `fix(auth): handle expired refresh tokens`', + '- `docs: update self-hosting guide`', + '', + `Received: \`${title}\``, + '', + '๐Ÿ”’ This PR has been automatically closed. Please update the title to match the format above and reopen the PR.' + ].join('\n'); + + await github.rest.issues.createComment({ + ...context.repo, + issue_number: context.issue.number, + body: message + }); + + await github.rest.pulls.update({ + ...context.repo, + pull_number: context.issue.number, + state: 'closed' + }); + + core.setFailed(`PR title does not follow Conventional Commits format: "${title}"`); 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..770bfb53a7e1 --- /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@v5 + 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..e1dceb031496 --- /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.15.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.13.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 40ff2e6d118e..25cc8370c899 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cipp", - "version": "10.5.5", + "version": "10.6.0", "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-devtools": "^5.100.10", - "@tanstack/react-query-persist-client": "^5.96.2", + "@tanstack/react-query": "^5.101.2", + "@tanstack/react-query-devtools": "^5.101.2", + "@tanstack/react-query-persist-client": "^5.101.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.3", "@tiptap/react": "^3.20.5", "@tiptap/starter-kit": "^3.20.5", "@vvo/tzdb": "^6.198.0", - "apexcharts": "5.14.0", + "apexcharts": "5.16.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.10", "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 6c7341794d6a..dc5c99678f74 100644 --- a/public/version.json +++ b/public/version.json @@ -1,3 +1,3 @@ { - "version": "10.5.5" + "version": "10.6.0" } diff --git a/src/components/BECRemediationReportButton.js b/src/components/BECRemediationReportButton.js index e8e9ddec81c0..30f705746ffa 100644 --- a/src/components/BECRemediationReportButton.js +++ b/src/components/BECRemediationReportButton.js @@ -434,23 +434,34 @@ const BECRemediationReportDocument = ({ } } + const formatSafelistValue = (value) => { + if (!value) return 'unchanged' + return Array.isArray(value) ? value.join(', ') || 'unchanged' : String(value) + } + // Calculate statistics const stats = { newRules: becData?.NewRules?.length || 0, + ruleChanges: becData?.InboxRuleChanges?.length || 0, newUsers: becData?.NewUsers?.length || 0, newApps: becData?.AddedApps?.length || 0, permissionChanges: becData?.MailboxPermissionChanges?.length || 0, mfaDevices: becData?.MFADevices?.length || 0, passwordChanges: becData?.ChangedPasswords?.length || 0, + trustedSenders: becData?.TrustedSenders?.length || 0, + blockedSenders: becData?.BlockedSenders?.length || 0, + safelistChanges: becData?.SafelistChanges?.length || 0, } // Determine threat level const calculateThreatLevel = () => { let threatScore = 0 if (stats.newRules > 0) threatScore += 3 + if (stats.ruleChanges > 0) threatScore += 3 if (stats.permissionChanges > 0) threatScore += 2 if (stats.newApps > 0) threatScore += 2 if (stats.newUsers > 5) threatScore += 1 + if (stats.safelistChanges > 0) threatScore += 2 // Check for suspicious rules (RSS folder moves) const hasSuspiciousRules = becData?.NewRules?.some((rule) => rule.MoveToFolder?.includes('RSS')) @@ -738,7 +749,7 @@ const BECRemediationReportDocument = ({ - {stats.newRules > 0 ? ( + {stats.newRules > 0 && ( <> โš  {stats.newRules} Mailbox Rule(s) Found @@ -758,6 +769,7 @@ const BECRemediationReportDocument = ({ {rule.MoveToFolder && `Moves to: ${rule.MoveToFolder}`} {rule.ForwardTo && `\nForwards to: ${rule.ForwardTo}`} {rule.DeleteMessage && '\nDeletes messages'} + {rule.RecentlyChanged && '\nCreated or changed in the last 7 days'} ))} @@ -767,7 +779,42 @@ const BECRemediationReportDocument = ({ )} - ) : ( + )} + {stats.ruleChanges > 0 && ( + <> + + + โš  {stats.ruleChanges} Rule Change(s) in the Last 7 Days + + + The audit log recorded inbox rules being created, changed or removed on this + mailbox. Rules that were removed after use are a common way for attackers to cover + their tracks. + + + + {becData.InboxRuleChanges.slice(0, 10).map((change, index) => ( + + + {change.Operation || 'Rule Change'}: {change.RuleName || 'Unnamed Rule'} + + + Date: {change.Date || 'Unknown'} + {'\n'} + By: {change.UserKey || 'Unknown'} + {change.Parameters && `\nParameters: ${change.Parameters}`} + + + ))} + {becData.InboxRuleChanges.length > 10 && ( + + ... and {becData.InboxRuleChanges.length - 10} more changes (see JSON export for + full list) + + )} + + )} + {stats.newRules === 0 && stats.ruleChanges === 0 && ( โœ“ No Suspicious Rules Found @@ -1065,6 +1112,74 @@ const BECRemediationReportDocument = ({ )} + {/* Check 7: Trusted & Blocked Senders */} + + Check 7: Trusted & Blocked Senders + + Why We Check This + + Attackers may add their own domain to the Trusted Senders list so their fraudulent + messages bypass spam filtering, or add finance/security domains to the Blocked + Senders list so warnings and alerts are hidden from the victim in the Junk Email + folder. + + + + {stats.safelistChanges > 0 && ( + <> + + + โš  {stats.safelistChanges} Safelist Change(s) in the Last 7 Days + + + The audit log recorded changes to the Trusted/Blocked Senders and Domains list on + this mailbox. Review each change carefully. + + + + {becData.SafelistChanges.slice(0, 10).map((change, index) => ( + + + {change.Operation || 'Safelist Change'} by {change.UserKey || 'Unknown'} + + + Date: {formatDate(change.Date)} + {'\n'} + Trusted: {formatSafelistValue(change.Trusted)} + {'\n'} + Blocked: {formatSafelistValue(change.Blocked)} + + + ))} + + )} + + {stats.trustedSenders > 0 && ( + + Trusted Senders/Domains ({stats.trustedSenders}) + {becData.TrustedSenders.slice(0, 15).join(', ')} + + )} + + {stats.blockedSenders > 0 && ( + + Blocked Senders/Domains ({stats.blockedSenders}) + {becData.BlockedSenders.slice(0, 15).join(', ')} + + )} + + {stats.trustedSenders === 0 && stats.blockedSenders === 0 && stats.safelistChanges === 0 && ( + + + โœ“ No Trusted or Blocked Senders Found + + + No trusted or blocked sender/domain entries were found on this mailbox. + + + )} + + {tenantName} - BEC Analysis Report for {userData?.displayName} @@ -1344,6 +1459,8 @@ const BECRemediationReportDocument = ({ {'\n'} Mailbox Rules Found: {stats.newRules} {'\n'} + Rule Changes: {stats.ruleChanges} + {'\n'} Permission Changes: {stats.permissionChanges} {'\n'} New Applications: {stats.newApps} @@ -1353,6 +1470,12 @@ const BECRemediationReportDocument = ({ MFA Devices: {stats.mfaDevices} {'\n'} Password Changes: {stats.passwordChanges} + {'\n'} + Trusted Senders: {stats.trustedSenders} + {'\n'} + Blocked Senders: {stats.blockedSenders} + {'\n'} + Safelist Changes: {stats.safelistChanges} diff --git a/src/components/CippCards/CippButtonCard.jsx b/src/components/CippCards/CippButtonCard.jsx index ca9429af940b..962076158e13 100644 --- a/src/components/CippCards/CippButtonCard.jsx +++ b/src/components/CippCards/CippButtonCard.jsx @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +import React, { useEffect } from 'react' import { Card, CardHeader, @@ -9,9 +9,9 @@ import { Accordion, AccordionSummary, AccordionDetails, -} from "@mui/material"; -import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; -import { useState } from "react"; +} from '@mui/material' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' +import { useState } from 'react' export default function CippButtonCard({ title, @@ -21,26 +21,26 @@ export default function CippButtonCard({ cardSx, cardActions, variant, - component = "card", + component = 'card', accordionExpanded = false, onAccordionChange, }) { - const [cardExpanded, setCardExpanded] = useState(accordionExpanded); + const [cardExpanded, setCardExpanded] = useState(accordionExpanded) useEffect(() => { if (accordionExpanded !== cardExpanded) { - setCardExpanded(accordionExpanded); + setCardExpanded(accordionExpanded) } - }, [accordionExpanded]); + }, [accordionExpanded]) useEffect(() => { if (onAccordionChange) { - onAccordionChange(cardExpanded); + onAccordionChange(cardExpanded) } - }, [cardExpanded]); + }, [cardExpanded]) return ( - {component === "card" && ( + {component === 'card' && ( <> {title && ( <> @@ -48,24 +48,23 @@ export default function CippButtonCard({ )} - + {isFetching ? : children} {CardButton && {CardButton}} )} - {component === "accordion" && ( + {component === 'accordion' && ( } onClick={() => setCardExpanded(!cardExpanded)} > - + - - + {isFetching ? : children} {CardButton && {CardButton}} @@ -73,5 +72,5 @@ export default function CippButtonCard({ )} - ); + ) } diff --git a/src/components/CippCards/CippChartCard.jsx b/src/components/CippCards/CippChartCard.jsx index 577a3f2bbaf1..cbf8c1306f07 100644 --- a/src/components/CippCards/CippChartCard.jsx +++ b/src/components/CippCards/CippChartCard.jsx @@ -43,6 +43,9 @@ const useChartOptions = (labels, chartType) => { }, xaxis: { + // Categories drive the bar/line axis labels and the tooltip title. Without this, a bar + // chart's tooltip falls back to the auto series name ("series-1") instead of the label. + categories: labels, labels: { show: true, rotate: 0, @@ -57,6 +60,11 @@ const useChartOptions = (labels, chartType) => { show: false, }, plotOptions: { + // distributed colors each bar (data point) from the colors array so a single-series bar + // chart keeps the per-item colors, and the tooltip shows the category name per bar. + bar: { + distributed: true, + }, pie: { expandOnClick: false, }, @@ -92,6 +100,7 @@ export const CippChartCard = ({ chartType = "donut", title, actions, + headerAction, onClick, totalLabel = "Total", customTotal, @@ -104,16 +113,15 @@ export const CippChartCard = ({ const total = customTotal !== undefined ? customTotal : calculatedTotal; useEffect(() => { if (chartType === "bar") { - setBarSeries( - labels.map((label, index) => ({ - data: [{ x: label, y: chartSeries[index] }], - })) - ); + // Single named series with the labels supplied via xaxis.categories. This keeps the tooltip + // title tied to the category (e.g. the site name) instead of an auto "series-1" name. + setBarSeries([{ name: totalLabel, data: chartSeries }]); } - }, [chartType, chartSeries.length, labels]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chartType, chartSeries.join(","), labels.join(","), totalLabel]); 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..c2869f3c7eb2 100644 --- a/src/components/CippComponents/AppRegistrationActions.jsx +++ b/src/components/CippComponents/AppRegistrationActions.jsx @@ -1,4 +1,5 @@ import { Launch, Delete, Key, Security, ContentCopy, Visibility, Edit } from '@mui/icons-material' +import isEqual from 'lodash/isEqual' import { CippFormComponent } from './CippFormComponent.jsx' import { CertificateCredentialRemovalForm } from './CertificateCredentialRemovalForm.jsx' @@ -41,6 +42,39 @@ const editInEntraAction = { ...headerLinkProps, } +// Shared client-secret fields (actions menu + inline card). "Custom date" reveals a date picker +// sent as a Unix ExpiryDate; otherwise the ExpiryMonths preset is used. +export const ADD_CLIENT_SECRET_FIELDS = [ + { + type: 'textField', + name: 'DisplayName', + label: 'Description', + placeholder: 'Secret description', + }, + { + type: 'autoComplete', + name: 'ExpiryMonths', + label: 'Expires In', + multiple: false, + creatable: false, + defaultValue: { label: '12 months', value: 12 }, + options: [ + { label: '3 months', value: 3 }, + { label: '6 months', value: 6 }, + { label: '12 months', value: 12 }, + { label: '24 months', value: 24 }, + { label: 'Custom date', value: 'custom' }, + ], + }, + { + type: 'datePicker', + name: 'ExpiryDate', + label: 'Custom expiry date', + dateTimeType: 'date', + condition: { field: 'ExpiryMonths', compareType: 'valueEq', compareValue: 'custom' }, + }, +] + export const getAppRegistrationPostAndDestructiveActions = (canWriteApplication) => [ { icon: , @@ -62,8 +96,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 +110,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,10 +151,30 @@ 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, }, + { + icon: , + label: 'Add Client Secret', + type: 'POST', + color: 'success', + multiPost: false, + allowResubmit: true, + url: '/api/ExecManageAppCredentials', + data: { + Id: 'id', + AppType: 'applications', + Action: 'Add', + CredentialType: 'password', + }, + fields: ADD_CLIENT_SECRET_FIELDS, + confirmText: + "Add a new client secret to '[displayName]'? The secret value is shown only once after creation.", + condition: () => canWriteApplication, + }, { icon: , label: 'Remove Password Credentials', @@ -191,6 +247,119 @@ export const getAppRegistrationPostAndDestructiveActions = (canWriteApplication) }, ] +const SIGN_IN_AUDIENCE_OPTIONS = [ + { label: 'This organization only (AzureADMyOrg)', value: 'AzureADMyOrg' }, + { label: 'Any Entra ID directory - multitenant (AzureADMultipleOrgs)', value: 'AzureADMultipleOrgs' }, + { + label: 'Any Entra ID directory + personal Microsoft accounts (AzureADandPersonalMicrosoftAccount)', + value: 'AzureADandPersonalMicrosoftAccount', + }, + { label: 'Personal Microsoft accounts only (PersonalMicrosoftAccount)', value: 'PersonalMicrosoftAccount' }, +] + +// Audiences that include personal Microsoft accounts only accept v2 access tokens, so +// api.requestedAccessTokenVersion must be 2 or Graph rejects the signInAudience change. +const AUDIENCES_REQUIRING_V2_TOKENS = ['AzureADandPersonalMicrosoftAccount', 'PersonalMicrosoftAccount'] + +const redirectUrisFromForm = (value) => + Array.isArray(value) ? value.map((item) => item?.value ?? item).filter(Boolean) : [] + +// customDataformatter builds the payload directly (bypassing the dialog's auto tenantFilter), so it +// must include tenantFilter from the detail page's actionsData.Tenant. +export const getAppRegistrationEditActions = (canWriteApplication) => [ + { + icon: , + label: 'Edit Authentication', + type: 'POST', + color: 'info', + multiPost: false, + allowResubmit: true, + setDefaultValues: true, + url: '/api/ExecApplication', + fields: [ + { + type: 'autoComplete', + name: 'signInAudience', + label: 'Supported account types', + multiple: false, + creatable: false, + options: SIGN_IN_AUDIENCE_OPTIONS, + }, + { + type: 'autoComplete', + name: 'web.redirectUris', + label: 'Web redirect URIs', + multiple: true, + freeSolo: true, + creatable: true, + options: [], + placeholder: 'https://... (press enter to add)', + }, + { + type: 'autoComplete', + name: 'spa.redirectUris', + label: 'Single-page application (SPA) redirect URIs', + multiple: true, + freeSolo: true, + creatable: true, + options: [], + placeholder: 'https://... (press enter to add)', + }, + { + type: 'autoComplete', + name: 'publicClient.redirectUris', + label: 'Public client / native redirect URIs', + multiple: true, + freeSolo: true, + creatable: true, + options: [], + placeholder: 'https://... or custom scheme (press enter to add)', + }, + ], + customDataformatter: (row, action, formData) => { + // Only send what actually changed, so audience and URI edits stay independent. + const Payload = {} + + const signInAudience = formData?.signInAudience?.value ?? formData?.signInAudience + if (signInAudience && signInAudience !== row.signInAudience) { + Payload.signInAudience = signInAudience + // Personal-account audiences require v2 access tokens; bump it in the same PATCH (merging the + // existing api object so custom scopes and pre-authorized apps are not wiped). + if ( + AUDIENCES_REQUIRING_V2_TOKENS.includes(signInAudience) && + row.api?.requestedAccessTokenVersion !== 2 + ) { + Payload.api = { ...(row.api || {}), requestedAccessTokenVersion: 2 } + } + } + + // redirectUris and redirectUriSettings can't be sent together, so a changed platform sends the + // existing object minus redirectUriSettings, with only redirectUris replaced. + ;[['web', row.web], ['spa', row.spa], ['publicClient', row.publicClient]].forEach( + ([key, existing]) => { + const newUris = redirectUrisFromForm(formData?.[key]?.redirectUris) + if (!isEqual([...newUris].sort(), [...(existing?.redirectUris || [])].sort())) { + const base = { ...(existing || {}) } + delete base.redirectUriSettings + Payload[key] = { ...base, redirectUris: newUris } + } + } + ) + + return { + tenantFilter: row.Tenant, + Id: row.id, + Type: 'applications', + Action: 'Update', + Payload, + } + }, + confirmText: + "Update the authentication settings (supported account types and redirect URIs) for '[displayName]'?", + condition: () => canWriteApplication, + }, +] + export const getAppRegistrationListActions = (canWriteApplication) => [ { icon: , @@ -207,5 +376,6 @@ export const getAppRegistrationListActions = (canWriteApplication) => [ export const getAppRegistrationDetailHeaderActions = (canWriteApplication) => [ ...entraLinkActions(true), editInEntraAction, + ...getAppRegistrationEditActions(canWriteApplication), ...getAppRegistrationPostAndDestructiveActions(canWriteApplication), ] diff --git a/src/components/CippComponents/AuthMethodCard.jsx b/src/components/CippComponents/AuthMethodCard.jsx index d684d8b4e694..4b9dd4b4828e 100644 --- a/src/components/CippComponents/AuthMethodCard.jsx +++ b/src/components/CippComponents/AuthMethodCard.jsx @@ -217,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/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..221214c357fd 100644 --- a/src/components/CippComponents/CippApiDialog.jsx +++ b/src/components/CippComponents/CippApiDialog.jsx @@ -6,15 +6,16 @@ import { DialogContent, DialogTitle, useMediaQuery, -} from "@mui/material"; -import { Stack } from "@mui/system"; -import { CippApiResults } from "./CippApiResults"; -import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; -import React, { useEffect, useState, useRef } from "react"; -import { useRouter } from "next/router"; -import { useForm, useFormState } from "react-hook-form"; -import { useSettings } from "../../hooks/use-settings"; -import CippFormComponent from "./CippFormComponent"; +} from '@mui/material' +import { Stack } from '@mui/system' +import { CippApiResults } from './CippApiResults' +import { ApiGetCall, ApiPostCall } from '../../api/ApiCall' +import React, { useEffect, useState, useRef } from 'react' +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 { @@ -29,128 +30,140 @@ export const CippApiDialog = (props) => { children, defaultvalues, ...other - } = props; - const router = useRouter(); - const linkOpenedRef = useRef(false); - const [addedFieldData, setAddedFieldData] = useState({}); - const [partialResults, setPartialResults] = useState([]); - const [isFormSubmitted, setIsFormSubmitted] = useState(false); - const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md")); + } = props + const router = useRouter() + const linkOpenedRef = useRef(false) + const [addedFieldData, setAddedFieldData] = useState({}) + const [partialResults, setPartialResults] = useState([]) + const [isFormSubmitted, setIsFormSubmitted] = useState(false) + const mdDown = useMediaQuery((theme) => theme.breakpoints.down('md')) if (mdDown) { - other.fullScreen = true; + other.fullScreen = true } const formHook = useForm({ - defaultValues: typeof defaultvalues === "function" ? defaultvalues(row) : defaultvalues || {}, - mode: "onChange", // Enable real-time validation - }); + defaultValues: typeof defaultvalues === 'function' ? defaultvalues(row) : defaultvalues || {}, + mode: 'onChange', // Enable real-time validation + }) // Get form state for validation - const { isValid } = useFormState({ control: formHook.control }); + const { isValid } = useFormState({ control: formHook.control }) useEffect(() => { if (createDialog.open) { - setIsFormSubmitted(false); - formHook.reset(typeof defaultvalues === "function" ? defaultvalues(row) : defaultvalues || {}); + setIsFormSubmitted(false) + formHook.reset(typeof defaultvalues === 'function' ? defaultvalues(row) : defaultvalues || {}) } - }, [createDialog.open, defaultvalues]); + }, [createDialog.open, defaultvalues]) const [getRequestInfo, setGetRequestInfo] = useState({ - url: "", + url: '', waiting: false, - queryKey: "", + queryKey: '', relatedQueryKeys: relatedQueryKeys ?? api.relatedQueryKeys ?? title, bulkRequest: api.multiPost === false, onResult: (result) => setPartialResults((prev) => [...prev, result]), - }); + }) const actionPostRequest = ApiPostCall({ urlFromData: true, relatedQueryKeys: relatedQueryKeys ?? api.relatedQueryKeys ?? title, bulkRequest: api.multiPost === false, onResult: (result) => { - setPartialResults((prev) => [...prev, result]); - api?.onSuccess?.(result); + setPartialResults((prev) => [...prev, result]) + api?.onSuccess?.(result) }, - }); + }) const actionGetRequest = ApiGetCall({ ...getRequestInfo, relatedQueryKeys: relatedQueryKeys ?? api.relatedQueryKeys ?? title, bulkRequest: api.multiPost === false, onResult: (result) => { - setPartialResults((prev) => [...prev, result]); - api?.onSuccess?.(result); + setPartialResults((prev) => [...prev, result]) + api?.onSuccess?.(result) }, - }); + }) + + // 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); + if (typeof api?.dataFunction === 'function') return api.dataFunction(row, dataObject) - let newData = {}; + let newData = {} if (api?.postEntireRow) { - return row; + return row } if (!dataObject) { - return dataObject; + return dataObject } Object.keys(dataObject).forEach((key) => { - const value = dataObject[key]; - - if (typeof value === "string" && value.startsWith("!")) { - newData[key] = value.slice(1); - } else if (typeof value === "string") { - newData[key] = row[value] ?? value; - } else if (typeof value === "boolean") { - newData[key] = value; - } else if (typeof value === "object" && value !== null) { - const processedValue = processActionData(value, row, replacementBehaviour); - if (replacementBehaviour !== "removeNulls" || Object.keys(processedValue).length > 0) { - newData[key] = processedValue; + const value = dataObject[key] + + if (typeof value === 'string' && value.startsWith('!')) { + newData[key] = value.slice(1) + } else if (typeof value === 'string') { + newData[key] = row[value] ?? value + } else if (typeof value === 'boolean') { + newData[key] = value + } else if (typeof value === 'object' && value !== null) { + const processedValue = processActionData(value, row, replacementBehaviour) + if (replacementBehaviour !== 'removeNulls' || Object.keys(processedValue).length > 0) { + newData[key] = processedValue } - } else if (replacementBehaviour !== "removeNulls") { - newData[key] = value; + } else if (replacementBehaviour !== 'removeNulls') { + newData[key] = value } - }); + }) - return newData; - }; + return newData + } - const tenantFilter = useSettings().currentTenant; + const tenantFilter = useSettings().currentTenant const handleActionClick = (row, action, formData) => { - setIsFormSubmitted(true); - let finalData = {}; - let isBulkRequest = false; - if (typeof api?.customDataformatter === "function") { - finalData = api.customDataformatter(row, action, formData); + setIsFormSubmitted(true) + let finalData = {} + let isBulkRequest = false + if (typeof api?.customDataformatter === 'function') { + finalData = api.customDataformatter(row, action, formData) // If customDataformatter returns an array, enable bulk request mode - isBulkRequest = Array.isArray(finalData); + isBulkRequest = Array.isArray(finalData) } else { - if (action.multiPost === undefined) action.multiPost = false; + if (action.multiPost === undefined) action.multiPost = false if (api.customFunction) { - action.customFunction(row, action, formData); - createDialog.handleClose(); - return; + action.customFunction(row, action, formData) + createDialog.handleClose() + return } // Helper function to get the correct tenant filter for a row const getRowTenantFilter = (rowData) => { // If we're in AllTenants mode and the row has a Tenant property, use that - if (tenantFilter === "AllTenants" && rowData?.Tenant) { - return rowData.Tenant; + if (tenantFilter === 'AllTenants' && rowData?.Tenant) { + return rowData.Tenant } // Otherwise use the current tenant filter - return tenantFilter; - }; + return tenantFilter + } - const processedActionData = processActionData(action.data, row, action.replacementBehaviour); + const processedActionData = processActionData(action.data, row, action.replacementBehaviour) if (!processedActionData || Object.keys(processedActionData).length === 0) { - console.warn("No data to process for action:", action); + console.warn('No data to process for action:', action) } else { // MULTI ROW CASES if (Array.isArray(row)) { @@ -159,32 +172,32 @@ export const CippApiDialog = (props) => { tenantFilter: getRowTenantFilter(singleRow), ...formData, ...addedFieldData, - }; - const itemData = { ...commonData }; + } + const itemData = { ...commonData } Object.keys(processedActionData).forEach((key) => { - const rowValue = singleRow[processedActionData[key]]; - itemData[key] = rowValue !== undefined ? rowValue : processedActionData[key]; - }); - return itemData; - }); + const rowValue = singleRow[processedActionData[key]] + itemData[key] = rowValue !== undefined ? rowValue : processedActionData[key] + }) + return itemData + }) const payload = { url: action.url, bulkRequest: !action.multiPost, data: arrayData, - }; + } - if (action.type === "POST") { - actionPostRequest.mutate(payload); - } else if (action.type === "GET") { + if (action.type === 'POST') { + actionPostRequest.mutate(payload) + } else if (action.type === 'GET') { setGetRequestInfo({ ...payload, waiting: true, queryKey: Date.now(), - }); + }) } - return; + return } } @@ -193,92 +206,95 @@ export const CippApiDialog = (props) => { tenantFilter: getRowTenantFilter(row), ...formData, ...addedFieldData, - }; + } // โœ… FIXED: DIRECT MERGE INSTEAD OF CORRUPT TRANSFORMATION finalData = { ...commonData, ...processedActionData, - }; + } } - if (action.type === "POST") { + if (action.type === 'POST') { actionPostRequest.mutate({ url: action.url, bulkRequest: isBulkRequest, data: finalData, - }); - } else if (action.type === "GET") { + }) + } else if (action.type === 'GET') { setGetRequestInfo({ url: action.url, waiting: true, queryKey: Date.now(), bulkRequest: isBulkRequest, data: finalData, - }); + }) } - }; + } useEffect(() => { if (dialogAfterEffect && (actionPostRequest.isSuccess || actionGetRequest.isSuccess)) { - dialogAfterEffect(actionPostRequest.data?.data || actionGetRequest.data); + dialogAfterEffect(actionPostRequest.data?.data || actionGetRequest.data) } - }, [actionPostRequest.isSuccess, actionGetRequest.isSuccess]); + }, [actionPostRequest.isSuccess, actionGetRequest.isSuccess]) - const onSubmit = (data) => handleActionClick(row, api, data); - const selectedType = api.type === "POST" ? actionPostRequest : actionGetRequest; + const onSubmit = (data) => handleActionClick(row, api, data) + const selectedType = api.type === 'POST' ? actionPostRequest : actionGetRequest 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") + (typeof val === 'string' && field.type === 'textField') || + (typeof val === 'boolean' && field.type === 'switch') ) { - formHook.setValue(field.name, val); - } else if (Array.isArray(val) && field.type === "autoComplete") { + formHook.setValue(targetName, val) + } else if (Array.isArray(val) && field.type === 'autoComplete') { const values = val .map((el) => el?.label && el?.value ? el - : typeof el === "string" || typeof el === "number" + : typeof el === 'string' || typeof el === 'number' ? { label: el, value: el } - : null, + : null ) - .filter(Boolean); - formHook.setValue(field.name, values); - } else if (field.type === "autoComplete" && val) { + .filter(Boolean) + formHook.setValue(targetName, values) + } else if (field.type === 'autoComplete' && val) { formHook.setValue( - field.name, - typeof val === "string" + targetName, + typeof val === 'string' ? { label: val, value: val } : val.label && val.value ? val - : undefined, - ); + : undefined + ) } - }); + }) } - }, [createDialog.open, api?.setDefaultValues]); + }, [createDialog.open, api?.setDefaultValues]) const escapeHtml = (text) => { - if (typeof text !== "string") return text; - const div = document.createElement("div"); - div.textContent = text; - return div.innerHTML; - }; + if (typeof text !== 'string') return text + const div = document.createElement('div') + div.textContent = text + return div.innerHTML + } const getRawNestedValue = (obj, path) => { return path - .split(".") - .reduce((acc, key) => (acc && acc[key] !== undefined ? acc[key] : undefined), obj); - }; + .split('.') + .reduce((acc, key) => (acc && acc[key] !== undefined ? acc[key] : undefined), obj) + } const getNestedValue = (obj, path) => { - const value = getRawNestedValue(obj, path); - return typeof value === "string" ? escapeHtml(value) : value; - }; + const value = getRawNestedValue(obj, path) + return typeof value === 'string' ? escapeHtml(value) : value + } // Handle link actions - opens the link when dialog opens, using ref to prevent duplicates useEffect(() => { @@ -289,75 +305,75 @@ export const CippApiDialog = (props) => { Object.keys(row).length > 0 && !linkOpenedRef.current ) { - linkOpenedRef.current = true; + linkOpenedRef.current = true const linkWithData = api.link.replace( /\[([^\]]+)\]/g, - (_, key) => getRawNestedValue(row, key) || `[${key}]`, - ); - if (linkWithData.startsWith("/") && !api?.external) { - router.push(linkWithData, undefined, { shallow: true }); + (_, key) => getRawNestedValue(row, key) || `[${key}]` + ) + if (linkWithData.startsWith('/') && !api?.external) { + router.push(linkWithData, undefined, { shallow: true }) } else { - window.open(linkWithData, api.target || "_blank"); + window.open(linkWithData, api.target || '_blank') } - createDialog.handleClose(); + createDialog.handleClose() } - }, [api.link, createDialog.open, row, router]); + }, [api.link, createDialog.open, row, router]) // Reset the ref when dialog closes so the same link can be opened again useEffect(() => { if (!createDialog.open) { - linkOpenedRef.current = false; + linkOpenedRef.current = false } - }, [createDialog.open]); + }, [createDialog.open]) useEffect(() => { if (api.noConfirm && !api.link) { - formHook.handleSubmit(onSubmit)(); - createDialog.handleClose(); + formHook.handleSubmit(onSubmit)() + createDialog.handleClose() } - }, [api.noConfirm, api.link]); + }, [api.noConfirm, api.link]) const handleClose = () => { - createDialog.handleClose(); - setPartialResults([]); - }; + createDialog.handleClose() + setPartialResults([]) + } - let confirmText; - if (typeof api?.confirmText === "string") { + let confirmText + if (typeof api?.confirmText === 'string') { if (!Array.isArray(row)) { confirmText = api.confirmText.replace( /\[([^\]]+)\]/g, - (_, key) => getNestedValue(row, key) || `[${key}]`, - ); + (_, 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, - (_, key) => getNestedValue(row[0], key) || `[${key}]`, - ); + (_, key) => getNestedValue(row[0], key) || `[${key}]` + ) } } else { const replaceTextInElement = (element) => { - if (!element) return element; - if (typeof element === "string") { + if (!element) return element + 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}]`, - ); + (_, key) => getNestedValue(row[0], key) || `[${key}]` + ) } - return element.replace(/\[([^\]]+)\]/g, (_, key) => getNestedValue(row, key) || `[${key}]`); + return element.replace(/\[([^\]]+)\]/g, (_, key) => getNestedValue(row, key) || `[${key}]`) } if (React.isValidElement(element)) { - const newChildren = React.Children.map(element.props.children, replaceTextInElement); - return React.cloneElement(element, {}, newChildren); + const newChildren = React.Children.map(element.props.children, replaceTextInElement) + return React.cloneElement(element, {}, newChildren) } - return element; - }; - confirmText = replaceTextInElement(api?.confirmText); + return element + } + confirmText = replaceTextInElement(api?.confirmText) } return ( @@ -372,7 +388,7 @@ export const CippApiDialog = (props) => { {children ? ( - typeof children === "function" ? ( + typeof children === 'function' ? ( children({ formHook, row, @@ -382,17 +398,43 @@ export const CippApiDialog = (props) => { ) ) : ( <> - {fields?.map((fieldProps, i) => ( - + {fields?.map((fieldProps, i) => { + const { condition, ...rest } = fieldProps + if ( + rest.api?.processFieldData && + rest.api?.data && + row && + !Array.isArray(row) + ) { + const processedData = processActionData(rest.api.data, row) + rest.api = { + ...rest.api, + data: processedData, + queryKey: + rest.api.queryKey ?? `${rest.api.url}-${JSON.stringify(processedData)}`, + } + } + const fieldElement = ( - - ))} + ) + return ( + + {condition ? ( + + {fieldElement} + + ) : ( + fieldElement + )} + + ) + })} )} @@ -409,12 +451,12 @@ export const CippApiDialog = (props) => { type="submit" disabled={!isValid || (isFormSubmitted && !allowResubmit)} > - {isFormSubmitted && allowResubmit ? "Reconfirm" : "Confirm"} + {isFormSubmitted && allowResubmit ? 'Reconfirm' : 'Confirm'} )} - ); -}; + ) +} 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' && ( + + ) : null; + + const addSecretDialog = canAddSecret ? ( + { + if (typeof onAdded === "function") { + onAdded(); + } + }} + row={{ id: graphObjectId, Tenant: tenantFilter }} + fields={ADD_CLIENT_SECRET_FIELDS} + api={{ + type: "POST", + url: "/api/ExecManageAppCredentials", + data: { + Id: "id", + AppType: `!${appType}`, + Action: "!Add", + CredentialType: "!password", + }, + }} + /> + ) : null; + + const rotateSecretDialog = canRotate ? ( + { + if (typeof onAdded === "function") { + onAdded(); + } else if (typeof onRemoved === "function") { + onRemoved(); + } + }} + row={{ id: graphObjectId, Tenant: tenantFilter, keyId: rotateCred?.keyId }} + api={{ + type: "POST", + url: "/api/ExecManageAppCredentials", + data: { + Id: "id", + AppType: `!${appType}`, + Action: "!Rotate", + CredentialType: "!password", + KeyId: "keyId", + }, + }} + confirmText="Rotate this client secret? A new secret with the same name is created and the current one is deleted immediately - anything still using the old secret will stop working until updated with the new value." + /> + ) : null; + if (!credentials.length) { return ( + {addSecretButton} No {credentialType === "password" ? "secrets" : "certificates"} configured. + {addSecretDialog} ); } return ( + {addSecretButton} {credentials.map((cred, idx) => { const keyId = cred.keyId; @@ -188,6 +274,11 @@ export const CippCredentialExpandList = ({ {canRemove && ( + {canRotate && ( + + Rotate + + )} + {addSecretDialog} + {rotateSecretDialog} ); }; @@ -218,4 +311,6 @@ CippCredentialExpandList.propTypes = { tenantFilter: PropTypes.string, canRemove: PropTypes.bool, onRemoved: PropTypes.func, + canAdd: PropTypes.bool, + onAdded: PropTypes.func, }; diff --git a/src/components/CippComponents/CippDateRangeFilter.jsx b/src/components/CippComponents/CippDateRangeFilter.jsx new file mode 100644 index 000000000000..e9123f568dbd --- /dev/null +++ b/src/components/CippComponents/CippDateRangeFilter.jsx @@ -0,0 +1,135 @@ +import { useState } from "react"; +import { + Accordion, + AccordionSummary, + AccordionDetails, + Button, + Typography, +} from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import { Grid } from "@mui/system"; +import { useForm } from "react-hook-form"; +import CippFormComponent from "./CippFormComponent"; + +/** + * Reusable relative / start-end date range filter (the one used on the Saved Logs page). + * Calls onApply({ RelativeTime, StartDate, EndDate }) when the user applies the filter. + * RelativeTime is formatted like "48h" / "7d"; StartDate/EndDate come from the date pickers. + */ +export const CippDateRangeFilter = ({ + onApply, + defaultTime = 7, + defaultInterval = { label: "Days", value: "d" }, + title = "Search Options", +}) => { + const formControl = useForm({ + mode: "onChange", + defaultValues: { + dateFilter: "relative", + Time: defaultTime, + Interval: defaultInterval, + }, + }); + + const [expanded, setExpanded] = useState(false); + + const onSubmit = (data) => { + if (data.dateFilter === "relative") { + onApply?.({ RelativeTime: `${data.Time}${data.Interval.value}`, StartDate: null, EndDate: null }); + } else if (data.dateFilter === "startEnd") { + onApply?.({ RelativeTime: null, StartDate: data.startDate, EndDate: data.endDate }); + } + }; + + return ( + setExpanded(!expanded)}> + }> + {title} + + +
+ + {/* Date Filter Type */} + + + + + {/* Relative Time Filter */} + {formControl.watch("dateFilter") === "relative" && ( + + + + + + + + + + + )} + + {/* Start and End Date Filters */} + {formControl.watch("dateFilter") === "startEnd" && ( + <> + + + + + + + + )} + + {/* Submit Button */} + + + + +
+
+
+ ); +}; + +export default CippDateRangeFilter; diff --git a/src/components/CippComponents/CippDeployCompliancePolicyDrawer.jsx b/src/components/CippComponents/CippDeployCompliancePolicyDrawer.jsx index 9ca5af4741e1..7cfc18fc1ee9 100644 --- a/src/components/CippComponents/CippDeployCompliancePolicyDrawer.jsx +++ b/src/components/CippComponents/CippDeployCompliancePolicyDrawer.jsx @@ -61,12 +61,17 @@ const MODE_CONFIG = { listTemplatesUrl: "/api/ListSensitivityLabelTemplates", templateQueryKey: "TemplateListSensitivityLabel", relatedQueryKeys: ["ListSensitivityLabel", "ListSensitivityLabelTemplates"], + supportsLabelColor: true, + // Sensitivity label templates store DisplayName/Name (Purview casing); the other template + // types store a lowercase 'name', which is the default label field below. + templateLabelField: (option) => option.DisplayName ?? option.Name ?? option.name, placeholder: `{ "Name": "Confidential", "DisplayName": "Confidential", "Tooltip": "Confidential data, do not share externally", "Comment": "Internal-only confidential classification", "ContentType": "File, Email", + "AdvancedSettings": { "color": "#40E0D0" }, "ApplyContentMarkingHeaderEnabled": true, "ApplyContentMarkingHeaderText": "Confidential - Internal Use Only", "ApplyContentMarkingHeaderFontColor": "#FF0000", @@ -110,6 +115,22 @@ const MODE_CONFIG = { }, }; +// A sensitivity label's custom color lives in the 'color' advanced setting. On stored templates it is +// either an explicit AdvancedSettings object or, for templates captured from Get-Label, an entry in the +// read-only Settings array ({Key, Value} object or the serialized string '[color, #RRGGBB]'). +const extractLabelColor = (template) => { + if (!template) return ""; + if (template.AdvancedSettings?.color) return template.AdvancedSettings.color; + for (const entry of template.Settings || []) { + if (entry?.Key?.toLowerCase?.() === "color") return entry.Value || ""; + if (typeof entry === "string") { + const match = entry.match(/^\[\s*color\s*,\s*(.*?)\s*\]$/i); + if (match) return match[1]; + } + } + return ""; +}; + export const CippDeployCompliancePolicyDrawer = ({ mode, buttonText, @@ -126,6 +147,7 @@ export const CippDeployCompliancePolicyDrawer = ({ selectedTenants: [], TemplateList: null, PowerShellCommand: "", + labelColor: "", }, }); @@ -136,8 +158,11 @@ export const CippDeployCompliancePolicyDrawer = ({ useEffect(() => { if (templateListVal?.value) { formControl.setValue("PowerShellCommand", JSON.stringify(templateListVal.value)); + if (config?.supportsLabelColor) { + formControl.setValue("labelColor", extractLabelColor(templateListVal.value)); + } } - }, [templateListVal, formControl]); + }, [templateListVal, formControl, config?.supportsLabelColor]); const deployPolicy = ApiPostCall({ urlFromData: true, @@ -150,6 +175,7 @@ export const CippDeployCompliancePolicyDrawer = ({ selectedTenants: [], TemplateList: null, PowerShellCommand: "", + labelColor: "", }); } }, [deployPolicy.isSuccess, formControl]); @@ -161,9 +187,21 @@ export const CippDeployCompliancePolicyDrawer = ({ const handleSubmit = () => { formControl.trigger(); if (!isValid) return; + const { labelColor, ...values } = formControl.getValues(); + if (config.supportsLabelColor && labelColor) { + // Merge the picked color into the JSON as the 'color' advanced setting; an explicit color + // already typed into the JSON is overridden by the picker since it is the visible control. + try { + const parsed = JSON.parse(values.PowerShellCommand); + parsed.AdvancedSettings = { ...(parsed.AdvancedSettings || {}), color: labelColor }; + values.PowerShellCommand = JSON.stringify(parsed, null, 2); + } catch { + // Invalid JSON in the textarea - submit unchanged and let the backend report the parse error. + } + } deployPolicy.mutate({ url: config.postUrl, - data: formControl.getValues(), + data: values, }); }; @@ -173,6 +211,7 @@ export const CippDeployCompliancePolicyDrawer = ({ selectedTenants: [], TemplateList: null, PowerShellCommand: "", + labelColor: "", }); }; @@ -234,7 +273,7 @@ export const CippDeployCompliancePolicyDrawer = ({ multiple={false} api={{ queryKey: config.templateQueryKey, - labelField: "name", + labelField: config.templateLabelField ?? "name", valueField: (option) => option, url: config.listTemplatesUrl, }} @@ -244,6 +283,18 @@ export const CippDeployCompliancePolicyDrawer = ({ + {config.supportsLabelColor && ( + + + + )} + { 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/CippEditSitePropertiesForm.jsx b/src/components/CippComponents/CippEditSitePropertiesForm.jsx new file mode 100644 index 000000000000..a8d2d7028bf6 --- /dev/null +++ b/src/components/CippComponents/CippEditSitePropertiesForm.jsx @@ -0,0 +1,285 @@ +import { useEffect } from 'react' +import { Alert, Divider, Skeleton, Typography } from '@mui/material' +import { Stack } from '@mui/system' +import CippFormComponent from './CippFormComponent' +import { CippFormCondition } from './CippFormCondition' +import { ApiGetCall } from '../../api/ApiCall' + +// Option lists for the Edit Site dialog; values match Invoke-ExecSetSiteProperties' whitelist. +const SHARING_CAPABILITY_OPTIONS = [ + { label: 'Disabled โ€” internal only', value: 'Disabled' }, + { label: 'Existing guests only', value: 'ExistingExternalUserSharingOnly' }, + { label: 'New and existing guests (sign-in required)', value: 'ExternalUserSharingOnly' }, + { label: 'Anyone โ€” including anonymous links', value: 'ExternalUserAndGuestSharing' }, +] +const LINK_TYPE_OPTIONS = [ + { label: 'Tenant default', value: 'None' }, + { label: 'Specific people (Direct)', value: 'Direct' }, + { label: 'Only people in your organization', value: 'Internal' }, + { label: 'Anyone with the link (Anonymous)', value: 'AnonymousAccess' }, +] +const LINK_PERMISSION_OPTIONS = [ + { label: 'Tenant default', value: 'None' }, + { label: 'View', value: 'View' }, + { label: 'Edit', value: 'Edit' }, +] +const DOMAIN_MODE_OPTIONS = [ + { label: 'No restriction', value: 'None' }, + { label: 'Allow only specific domains', value: 'AllowList' }, + { label: 'Block specific domains', value: 'BlockList' }, +] +const LOCK_STATE_OPTIONS = [ + { label: 'Unlocked', value: 'Unlock' }, + { label: 'Read Only', value: 'ReadOnly' }, + { label: 'No Access (site blocked)', value: 'NoAccess' }, +] + +// Form body of the Edit Site action. Loads the site's current admin properties and +// prefills the form so submitting without changes is a no-op write of current values. +export const CippEditSitePropertiesForm = ({ formHook, row, tenantFilter }) => { + const siteRow = Array.isArray(row) ? row[0] : row + // Group-connected sites only accept sharing level, link defaults, lock state and storage + // quota; SPO rejects Title, domain restrictions, anonymous-link override and version policy. + const isGroupSite = siteRow?.rootWebTemplate === 'Group' + const propsApi = ApiGetCall({ + url: '/api/ListSiteProperties', + data: { + tenantFilter: siteRow?.Tenant ?? tenantFilter, + SiteUrl: siteRow?.webUrl, + }, + queryKey: `SiteProperties-${siteRow?.webUrl}`, + }) + + useEffect(() => { + const p = propsApi.data?.Results + // autoComplete fields hold { label, value } objects; map raw values to their option. + const toOption = (options, value) => + options.find((o) => o.value === value) ?? (value ? { label: value, value } : null) + if (p && typeof p === 'object' && p.Url) { + formHook.reset({ + Title: p.Title ?? '', + SharingCapability: toOption(SHARING_CAPABILITY_OPTIONS, p.SharingCapability), + DefaultSharingLinkType: toOption(LINK_TYPE_OPTIONS, p.DefaultSharingLinkType), + DefaultLinkPermission: toOption(LINK_PERMISSION_OPTIONS, p.DefaultLinkPermission), + SharingDomainRestrictionMode: toOption( + DOMAIN_MODE_OPTIONS, + p.SharingDomainRestrictionMode, + ), + SharingAllowedDomainList: p.SharingAllowedDomainList ?? '', + SharingBlockedDomainList: p.SharingBlockedDomainList ?? '', + OverrideTenantAnonymousLinkExpirationPolicy: + !!p.OverrideTenantAnonymousLinkExpirationPolicy, + AnonymousLinkExpirationInDays: p.AnonymousLinkExpirationInDays ?? 0, + LockState: toOption(LOCK_STATE_OPTIONS, p.LockState), + StorageMaximumLevel: p.StorageMaximumLevel, + StorageWarningLevel: p.StorageWarningLevel, + InheritVersionPolicyFromTenant: !!p.InheritVersionPolicyFromTenant, + EnableAutoExpirationVersionTrim: !!p.EnableAutoExpirationVersionTrim, + MajorVersionLimit: p.MajorVersionLimit ?? 0, + ExpireVersionsAfterDays: p.ExpireVersionsAfterDays ?? 0, + }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [propsApi.isSuccess, propsApi.data]) + + if (propsApi.isFetching) { + return ( + + + + + + ) + } + if (propsApi.isError || typeof propsApi.data?.Results === 'string') { + return ( + + Failed to load site properties.{' '} + {typeof propsApi.data?.Results === 'string' ? propsApi.data.Results : ''} + + ) + } + + return ( + + {isGroupSite && ( + + Group-connected (Team) site: only the sharing level, default link settings, lock state + and storage limits can be managed here. Rename the site by renaming its M365 group. + + )} + {!isGroupSite && ( + <> + General + + + + )} + External Sharing + + + + {!isGroupSite && ( + + )} + + + + + + + {!isGroupSite && ( + + )} + + + + + + Lock State & Storage + + + Storage limits only apply when the tenant uses manual site storage limits. Lock state + changes can take a few minutes to fully propagate. + + + + + {!isGroupSite && ( + <> + + File Version Policy + + + )} + + + + + + + + + ) +} + diff --git a/src/components/CippComponents/CippFormComponent.jsx b/src/components/CippComponents/CippFormComponent.jsx index d8b2f9d48443..51f32df9a45c 100644 --- a/src/components/CippComponents/CippFormComponent.jsx +++ b/src/components/CippComponents/CippFormComponent.jsx @@ -266,6 +266,64 @@ export const CippFormComponent = (props) => { )} ); + case "colorPicker": + return ( + <> +
+ ( + + field.onChange(e.target.value)} + style={{ + width: "50px", + height: "40px", + border: "1px solid #ddd", + borderRadius: "4px", + cursor: "pointer", + padding: 0, + }} + /> + + + )} + /> +
+ {get(errors, convertedName, {})?.message && ( + + {get(errors, convertedName, {})?.message} + + )} + {helperText && ( + + {helperText} + + )} + + ); case "textFieldWithVariables": return ( <> @@ -445,7 +503,15 @@ export const CippFormComponent = (props) => { name={convertedName} control={formControl.control} defaultValue={defaultValue} - rules={validators} + rules={ + // Pass row as third parameter, same as autoComplete fields + typeof validators?.validate === "function" + ? { + ...validators, + validate: (value, formValues) => validators.validate(value, formValues, row), + } + : validators + } render={({ field }) => { return ( { - // 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/CippIntuneDeviceActions.jsx b/src/components/CippComponents/CippIntuneDeviceActions.jsx new file mode 100644 index 000000000000..0fa86320f95d --- /dev/null +++ b/src/components/CippComponents/CippIntuneDeviceActions.jsx @@ -0,0 +1,373 @@ +import { EyeIcon } from '@heroicons/react/24/outline' +import { + Sync, + RestartAlt, + LocationOn, + Password, + PasswordOutlined, + Key, + Edit, + Security, + FindInPage, + Shield, + AutoMode, + Recycling, + ManageAccounts, +} from '@mui/icons-material' + +// Shared between the MEM devices list page and the View Device detail page. +// Link-type actions (View Device / View in Intune) render on the list page but are +// filtered out of the detail page's ActionsMenu (see components/actions-menu.js), +// so a single array is safe to reuse as-is. +export const getIntuneDeviceActions = ({ tenantFilter } = {}) => [ + { + label: 'View Device', + link: `/endpoint/MEM/devices/device?deviceId=[id]`, + color: 'info', + icon: , + multiPost: false, + }, + { + label: 'View in Intune', + link: `https://intune.microsoft.com/${tenantFilter}/#view/Microsoft_Intune_Devices/DeviceSettingsMenuBlade/~/overview/mdmDeviceId/[id]`, + color: 'info', + icon: , + target: '_blank', + multiPost: false, + external: true, + }, + { + label: 'Change Primary User', + type: 'POST', + icon: , + url: '/api/ExecDeviceAction', + data: { + GUID: 'id', + Action: '!users', + }, + fields: [ + { + type: 'autoComplete', + name: 'user', + label: 'Select User', + multiple: false, + creatable: false, + api: { + url: '/api/ListGraphRequest', + data: { + Endpoint: 'users', + $select: 'id,displayName,userPrincipalName', + $top: 999, + $count: true, + }, + queryKey: 'ListUsersAutoComplete', + dataKey: 'Results', + labelField: (user) => `${user.displayName} (${user.userPrincipalName})`, + valueField: 'id', + addedField: { + userPrincipalName: 'userPrincipalName', + }, + showRefresh: true, + }, + }, + ], + confirmText: 'Select the User to set as the primary user for [deviceName]', + }, + { + label: 'Rename Device', + type: 'POST', + icon: , + url: '/api/ExecDeviceAction', + data: { + GUID: 'id', + Action: 'setDeviceName', + }, + confirmText: 'Enter the new name for the device', + fields: [ + { + type: 'textField', + name: 'input', + label: 'New Device Name', + required: true, + }, + ], + }, + { + label: 'Sync Device', + type: 'POST', + icon: , + url: '/api/ExecDeviceAction', + data: { + GUID: 'id', + Action: 'syncDevice', + }, + confirmText: 'Are you sure you want to sync [deviceName]?', + }, + { + label: 'Reboot Device', + type: 'POST', + icon: , + url: '/api/ExecDeviceAction', + data: { + GUID: 'id', + Action: 'rebootNow', + }, + confirmText: 'Are you sure you want to reboot [deviceName]?', + }, + { + label: 'Locate Device', + type: 'POST', + icon: , + url: '/api/ExecDeviceAction', + data: { + GUID: 'id', + Action: 'locateDevice', + }, + confirmText: 'Are you sure you want to locate [deviceName]?', + }, + { + label: 'Retrieve LAPS password', + type: 'POST', + icon: , + url: '/api/ExecGetLocalAdminPassword', + data: { + GUID: 'azureADDeviceId', + }, + condition: (row) => row.operatingSystem === 'Windows', + confirmText: 'Are you sure you want to retrieve the local admin password for [deviceName]?', + }, + { + label: 'Rotate Local Admin Password', + type: 'POST', + icon: , + url: '/api/ExecDeviceAction', + data: { + GUID: 'id', + Action: 'RotateLocalAdminPassword', + }, + condition: (row) => row.operatingSystem === 'Windows', + confirmText: 'Are you sure you want to rotate the password for [deviceName]?', + }, + { + label: 'Retrieve BitLocker Keys', + type: 'POST', + icon: , + url: '/api/ExecGetRecoveryKey', + data: { + GUID: 'azureADDeviceId', + RecoveryKeyType: '!BitLocker', + }, + condition: (row) => row.operatingSystem === 'Windows', + confirmText: 'Are you sure you want to retrieve the BitLocker keys for [deviceName]?', + }, + { + label: 'Retrieve FileVault Key', + type: 'POST', + icon: , + url: '/api/ExecGetRecoveryKey', + data: { + GUID: 'id', + RecoveryKeyType: '!FileVault', + }, + condition: (row) => row.operatingSystem === 'macOS', + confirmText: 'Are you sure you want to retrieve the FileVault key for [deviceName]?', + }, + { + label: 'Reset Passcode', + type: 'POST', + icon: , + url: '/api/ExecDevicePasscodeAction', + data: { + GUID: 'id', + Action: 'resetPasscode', + }, + condition: (row) => row.operatingSystem === 'Android', + confirmText: + 'Are you sure you want to reset the passcode for [deviceName]? A new passcode will be generated and displayed.', + }, + { + label: 'Remove Passcode', + type: 'POST', + icon: , + url: '/api/ExecDevicePasscodeAction', + data: { + GUID: 'id', + Action: 'resetPasscode', + }, + condition: (row) => row.operatingSystem === 'iOS', + confirmText: + 'Are you sure you want to remove the passcode from [deviceName]? This will remove the device passcode requirement.', + }, + { + label: 'Windows Defender Full Scan', + type: 'POST', + icon: , + url: '/api/ExecDeviceAction', + data: { + GUID: 'id', + Action: 'WindowsDefenderScan', + quickScan: false, + }, + confirmText: 'Are you sure you want to perform a full scan on [deviceName]?', + }, + { + label: 'Windows Defender Quick Scan', + type: 'POST', + icon: , + url: '/api/ExecDeviceAction', + data: { + GUID: 'id', + Action: 'WindowsDefenderScan', + quickScan: true, + }, + confirmText: 'Are you sure you want to perform a quick scan on [deviceName]?', + }, + { + label: 'Update Windows Defender', + type: 'POST', + icon: , + url: '/api/ExecDeviceAction', + data: { + GUID: 'id', + Action: 'windowsDefenderUpdateSignatures', + }, + confirmText: + 'Are you sure you want to update the Windows Defender signatures for [deviceName]?', + }, + // This endpoint currently does not work, Graph just returns an error. Leaving this here for now in case it is fixed in the future. -Zac + // { + // label: 'Generate logs and ship to MEM', + // type: 'POST', + // icon: , + // url: '/api/ExecDeviceAction', + // data: { + // GUID: 'id', + // Action: 'createDeviceLogCollectionRequest', + // }, + // condition: (row) => row.operatingSystem === 'Windows', + // confirmText: + // 'Are you sure you want to generate logs for device [deviceName] and ship these to MEM?', + // }, + { + label: 'Fresh Start (Remove user data)', + type: 'POST', + icon: , + url: '/api/ExecDeviceAction', + data: { + GUID: 'id', + Action: 'cleanWindowsDevice', + keepUserData: false, + }, + condition: (row) => row.operatingSystem === 'Windows', + confirmText: 'Are you sure you want to Fresh Start [deviceName]?', + }, + { + label: 'Fresh Start (Do not remove user data)', + type: 'POST', + icon: , + url: '/api/ExecDeviceAction', + data: { + GUID: 'id', + Action: 'cleanWindowsDevice', + keepUserData: true, + }, + condition: (row) => row.operatingSystem === 'Windows', + confirmText: 'Are you sure you want to Fresh Start [deviceName]?', + }, + { + label: 'Wipe Device, keep enrollment data', + type: 'POST', + icon: , + url: '/api/ExecDeviceAction', + data: { + GUID: 'id', + Action: 'cleanWindowsDevice', + keepUserData: false, + keepEnrollmentData: true, + }, + condition: (row) => row.operatingSystem === 'Windows', + confirmText: 'Are you sure you want to wipe [deviceName], and retain enrollment data?', + }, + { + label: 'Wipe Device, remove enrollment data', + type: 'POST', + icon: , + url: '/api/ExecDeviceAction', + data: { + GUID: 'id', + Action: 'cleanWindowsDevice', + keepUserData: false, + keepEnrollmentData: false, + }, + condition: (row) => row.operatingSystem === 'Windows', + confirmText: 'Are you sure you want to wipe [deviceName], and remove enrollment data?', + }, + { + label: 'Wipe Device, keep enrollment data, and continue at powerloss', + type: 'POST', + icon: , + url: '/api/ExecDeviceAction', + data: { + GUID: 'id', + Action: 'cleanWindowsDevice', + keepEnrollmentData: true, + keepUserData: false, + useProtectedWipe: true, + }, + condition: (row) => row.operatingSystem === 'Windows', + confirmText: + 'Are you sure you want to wipe [deviceName]? This will retain enrollment data. Continuing at powerloss may cause boot issues if wipe is interrupted.', + }, + { + label: 'Wipe Device, remove enrollment data, and continue at powerloss', + type: 'POST', + icon: , + url: '/api/ExecDeviceAction', + data: { + GUID: 'id', + Action: 'cleanWindowsDevice', + keepEnrollmentData: false, + keepUserData: false, + useProtectedWipe: true, + }, + condition: (row) => row.operatingSystem === 'Windows', + confirmText: + 'Are you sure you want to wipe [deviceName]? This will also remove enrollment data. Continuing at powerloss may cause boot issues if wipe is interrupted.', + }, + { + label: 'Autopilot Reset', + type: 'POST', + icon: , + url: '/api/ExecDeviceAction', + data: { + GUID: 'id', + Action: 'wipe', + keepUserData: 'false', + keepEnrollmentData: 'true', + }, + condition: (row) => row.operatingSystem === 'Windows', + confirmText: 'Are you sure you want to Autopilot Reset [deviceName]?', + }, + { + label: 'Delete device', + type: 'POST', + icon: , + url: '/api/ExecDeviceAction', + data: { + GUID: 'id', + Action: 'delete', + }, + confirmText: 'Are you sure you want to delete [deviceName]?', + }, + { + label: 'Retire device', + type: 'POST', + icon: , + url: '/api/ExecDeviceAction', + data: { + GUID: 'id', + Action: 'retire', + }, + confirmText: 'Are you sure you want to retire [deviceName]?', + }, +] 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/CippMailboxPermissionsDialog.jsx b/src/components/CippComponents/CippMailboxPermissionsDialog.jsx index 8306089a8008..73c03f830d76 100644 --- a/src/components/CippComponents/CippMailboxPermissionsDialog.jsx +++ b/src/components/CippComponents/CippMailboxPermissionsDialog.jsx @@ -1,26 +1,38 @@ -import { Box, Stack } from "@mui/material"; -import { useEffect } from "react"; -import CippFormComponent from "./CippFormComponent"; -import { useWatch } from "react-hook-form"; +import { Box, FormControlLabel, Stack, Switch } from '@mui/material' +import { useEffect } from 'react' +import CippFormComponent from './CippFormComponent' +import { useWatch } from 'react-hook-form' +import { GroupHeader, GroupItems } from './CippAutocompleteGrouping' -const CippMailboxPermissionsDialog = ({ - formHook, - combinedOptions, - isUserGroupLoading, - defaultAutoMap = false +const CippMailboxPermissionsDialog = ({ + formHook, + combinedOptions, + isUserGroupLoading, + includeGroups, + onIncludeGroupsChange, + defaultAutoMap = false, }) => { const fullAccess = useWatch({ control: formHook.control, - name: "permissions.AddFullAccess", - }); + name: 'permissions.AddFullAccess', + }) // Set the default AutoMap value when component mounts useEffect(() => { - formHook.setValue("permissions.AutoMap", defaultAutoMap); - }, [formHook, defaultAutoMap]); + formHook.setValue('permissions.AutoMap', defaultAutoMap) + }, [formHook, defaultAutoMap]) return ( + onIncludeGroupsChange(checked)} + /> + } + label="Include mail-enabled security groups" + /> option.group} + renderGroup={(params) => ( +
  • + {params.group} + {params.children} +
  • + )} />
    @@ -51,6 +70,13 @@ const CippMailboxPermissionsDialog = ({ isFetching={isUserGroupLoading} creatable={false} options={combinedOptions} + groupBy={(option) => option.group} + renderGroup={(params) => ( +
  • + {params.group} + {params.children} +
  • + )} />
    @@ -62,10 +88,17 @@ const CippMailboxPermissionsDialog = ({ isFetching={isUserGroupLoading} creatable={false} options={combinedOptions} + groupBy={(option) => option.group} + renderGroup={(params) => ( +
  • + {params.group} + {params.children} +
  • + )} />
    - ); -}; + ) +} -export default CippMailboxPermissionsDialog; +export default CippMailboxPermissionsDialog 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/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/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/CippSchedulerDrawer.jsx b/src/components/CippComponents/CippSchedulerDrawer.jsx index 98510e77ce53..e26e989d3bcc 100644 --- a/src/components/CippComponents/CippSchedulerDrawer.jsx +++ b/src/components/CippComponents/CippSchedulerDrawer.jsx @@ -80,7 +80,7 @@ export const CippSchedulerDrawer = ({ ? "Clone this task with the same configuration. Modify the settings as needed and save to create a new task." : taskId ? "Edit the task configuration. Changes will be applied when you save." - : "Create a scheduled task or event-triggered task. Scheduled tasks run PowerShell commands at specified times, while triggered tasks respond to events like Azure AD changes."} + : "Create a scheduled task or event-triggered task. Scheduled tasks run PowerShell commands at specified times, while triggered tasks respond to events like Microsoft Entra changes."} { 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/CippSiteRecycleBinDialog.jsx b/src/components/CippComponents/CippSiteRecycleBinDialog.jsx new file mode 100644 index 000000000000..6e1ecb6b65ab --- /dev/null +++ b/src/components/CippComponents/CippSiteRecycleBinDialog.jsx @@ -0,0 +1,77 @@ +import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material' +import { RestoreFromTrash } from '@mui/icons-material' +import { CippDataTable } from '../CippTable/CippDataTable' +import { usePermissions } from '../../hooks/use-permissions' + +// Custom-component action dialog: lists a site's recycle bin (first + second stage) and +// restores selected items via ExecRestoreRecycleBinItems. +export const CippSiteRecycleBinDialog = ({ + row, + tenantFilter, + drawerVisible, + setDrawerVisible, +}) => { + const siteRow = Array.isArray(row) ? row[0] : row + const siteUrl = siteRow?.webUrl + const tenant = siteRow?.Tenant ?? tenantFilter + const { checkPermissions } = usePermissions() + const canRestore = checkPermissions(['Sharepoint.SiteRecycleBin.ReadWrite']) + + const actions = [ + { + label: 'Restore Item', + type: 'POST', + icon: , + url: '/api/ExecRestoreRecycleBinItems', + data: { + Ids: 'Id', + ItemNames: 'LeafName', + // Literal values: these keys do not exist on the recycle bin rows, so the + // action data mapper passes them through as-is. + SiteUrl: siteUrl, + tenantFilter: tenant, + }, + confirmText: 'Restore [LeafName] from the recycle bin?', + condition: () => canRestore, + multiPost: false, + }, + ] + + return ( + setDrawerVisible(false)}> + + Recycle Bin{siteRow?.displayName ? ` โ€” ${siteRow.displayName}` : ''} + + + + + + + + + ) +} diff --git a/src/components/CippComponents/CippTemplateFieldRenderer.jsx b/src/components/CippComponents/CippTemplateFieldRenderer.jsx index f9a3cebf7e4e..ca57ca5cbb15 100644 --- a/src/components/CippComponents/CippTemplateFieldRenderer.jsx +++ b/src/components/CippComponents/CippTemplateFieldRenderer.jsx @@ -175,9 +175,9 @@ const CippTemplateFieldRenderer = ({ options: [ { label: "Not Configured", value: "notConfigured" }, { label: "Disabled", value: "disabled" }, - { label: "Enabled for Azure AD Joined", value: "enabledForAzureAd" }, + { label: "Enabled for Microsoft Entra Joined", value: "enabledForAzureAd" }, { - label: "Enabled for Azure AD and Hybrid Joined", + label: "Enabled for Microsoft Entra and Hybrid Joined", value: "enabledForAzureAdAndHybrid", }, ], diff --git a/src/components/CippComponents/CippTenantSelector.jsx b/src/components/CippComponents/CippTenantSelector.jsx index 0b2c3ce62508..2cca5a975d05 100644 --- a/src/components/CippComponents/CippTenantSelector.jsx +++ b/src/components/CippComponents/CippTenantSelector.jsx @@ -97,7 +97,7 @@ export const CippTenantSelector = React.forwardRef((props, ref) => { }, { key: "Compliance_Portal", - label: "Compliance Portal", + label: "Purview Portal", link: `https://purview.microsoft.com/?tid=${currentTenant?.addedFields?.customerId}`, icon: "ShieldMoon", }, diff --git a/src/components/CippComponents/CippTranslations.jsx b/src/components/CippComponents/CippTranslations.jsx index 2f83966b3c5d..6e7e7dd99020 100644 --- a/src/components/CippComponents/CippTranslations.jsx +++ b/src/components/CippComponents/CippTranslations.jsx @@ -1,12 +1,32 @@ export const CippTranslations = { userPrincipalName: 'User Principal Name', + aadRegistered: 'Microsoft Entra Registered', + azureADRegistered: 'Microsoft Entra Registered', + azureActiveDirectoryDeviceId: 'Microsoft Entra Device ID', + azureADDeviceId: 'Microsoft Entra Device ID', aiTool: 'AI Tool', + fileName: 'File Name', + workload: 'Workload', + siteName: 'Site', + siteUrl: 'Site URL', + driveName: 'Library', + itemType: 'Type', + itemUrl: 'File URL', + classification: 'Classification', + linkScope: 'Link Scope', + linkType: 'Link Type', + sharedWith: 'Shared With', + linkUrl: 'Link URL', + hasPassword: 'Password Protected', + expirationDateTime: 'Expires', applicationId: 'Application ID', signInsLast7Days: 'Sign-ins (7 Days)', signIns: 'Sign-ins', activeUsersLast7Days: 'Active Users (7 Days)', firstConsentedDateTime: 'First Consented', deviceCount: 'Devices', + managedDevices: 'Devices', + status: 'Status', displayName: 'Display Name', mail: 'Mail', mobilePhone: 'Mobile Phone', @@ -45,7 +65,7 @@ export const CippTranslations = { portal_azure: 'Azure', portal_intune: 'Intune', portal_security: 'Security', - portal_compliance: 'Compliance', + portal_compliance: 'Purview', portal_sharepoint: 'SharePoint', portal_platform: 'Power Platform', portal_bi: 'Power BI', diff --git a/src/components/CippComponents/CippUserActions.jsx b/src/components/CippComponents/CippUserActions.jsx index deaa03a68fac..510166e065bb 100644 --- a/src/components/CippComponents/CippUserActions.jsx +++ b/src/components/CippComponents/CippUserActions.jsx @@ -17,6 +17,7 @@ import { PersonOff, PhonelinkLock, PhonelinkSetup, + Refresh, Shortcut, EditAttributes, CloudSync, @@ -25,10 +26,11 @@ import { import { getCippLicenseTranslation } from '../../utils/get-cipp-license-translation' import { useSettings } from '../../hooks/use-settings.js' import { usePermissions } from '../../hooks/use-permissions' -import { Tooltip, Box, Divider, Typography } from '@mui/material' +import { Tooltip, Box, Divider, Typography, Alert, Skeleton, Link, IconButton } from '@mui/material' import CippFormComponent from './CippFormComponent' import { CippFormCondition } from './CippFormCondition' import { useWatch } from 'react-hook-form' +import { ApiGetCall } from '../../api/ApiCall' // Separate component for Manage Licenses form to avoid hook issues const ManageLicensesForm = ({ formControl, tenant }) => { @@ -184,6 +186,129 @@ const ManageLicensesForm = ({ formControl, tenant }) => { ) } +// Separate component for the Temporary Access Pass form so it can query the tenant's +// TAP policy to validate the allowed lifetime range and enforce one-time use when forced +const TemporaryAccessPassForm = ({ formControl, row }) => { + const tenantFilter = useSettings().currentTenant + const rowData = Array.isArray(row) ? row[0] : row + const tenant = tenantFilter === 'AllTenants' && rowData?.Tenant ? rowData.Tenant : tenantFilter + + const tapPolicy = ApiGetCall({ + url: '/api/ListGraphRequest', + data: { + Endpoint: + 'policies/authenticationMethodsPolicy/authenticationMethodConfigurations/TemporaryAccessPass', + tenantFilter: tenant, + }, + queryKey: `TAPPolicy-${tenant}`, + }) + + const policy = tapPolicy.data?.Results?.[0] + const oneTimeUseForced = policy?.isUsableOnce === true + + useEffect(() => { + if (!policy) return + // Deferred a tick: CippApiDialog resets the form in a mount effect that runs after + // this child effect, so an immediate setValue would be wiped when the query is cached + const timer = setTimeout(() => { + formControl.setValue('isUsableOnce', oneTimeUseForced) + }, 0) + return () => clearTimeout(timer) + }, [tapPolicy.dataUpdatedAt]) + + if (tapPolicy.isLoading) { + return ( + <> + + + + + ) + } + + return ( + <> + {tapPolicy.isSuccess && policy?.state !== 'enabled' && ( + + + tapPolicy.refetch()} + disabled={tapPolicy.isFetching} + > + + + + + } + > + Temporary Access Pass is not enabled in this tenant's authentication method policy and + creating a TAP will fail. Enable it on the{' '} + + Authentication Methods + {' '} + page first, then re-check. + + )} + + + + + + + + + ) +} + // Separate component for Out of Office form to avoid hook issues const OutOfOfficeForm = ({ formControl }) => { // Send the browser's IANA timezone so the API can display local times in the response @@ -429,25 +554,7 @@ export const useCippUserActions = () => { icon: , url: '/api/ExecCreateTAP', data: { ID: 'userPrincipalName' }, - fields: [ - { - type: 'number', - name: 'lifetimeInMinutes', - label: 'Lifetime (Minutes)', - placeholder: 'Leave blank for default', - }, - { - type: 'switch', - name: 'isUsableOnce', - label: 'One-time use only', - }, - { - type: 'datePicker', - name: 'startDateTime', - label: 'Start Date/Time (leave blank for immediate)', - dateTimeType: 'datetime', - }, - ], + children: ({ formHook, row }) => , confirmText: 'Are you sure you want to create a Temporary Access Pass for [userPrincipalName]?', multiPost: false, @@ -733,6 +840,15 @@ export const useCippUserActions = () => { icon: , url: '/api/ExecDisableUser', data: { ID: 'id' }, + // Pre-select the current sign-in state; leave unselected when the + // selected rows have mixed states. String values match what a radio + // click produces (e.target.value is always a string). + defaultvalues: (row) => { + const states = [...new Set((Array.isArray(row) ? row : [row]).map((r) => r?.accountEnabled))] + return states.length === 1 && typeof states[0] === 'boolean' + ? { Enable: String(states[0]) } + : {} + }, fields: [ { type: 'radio', @@ -742,7 +858,22 @@ export const useCippUserActions = () => { { label: 'Enabled', value: true }, { label: 'Disabled', value: false }, ], - validators: { required: 'Please select a sign-in state' }, + validators: { + required: 'Please select a sign-in state', + validate: (value, formValues, row) => { + const states = [ + ...new Set((Array.isArray(row) ? row : [row]).map((r) => r?.accountEnabled)), + ] + if ( + states.length === 1 && + typeof states[0] === 'boolean' && + String(value) === String(states[0]) + ) { + return 'Sign-in state is unchanged' + } + return true + }, + }, }, ], confirmText: 'Are you sure you want to set the sign-in state for [userPrincipalName]?', @@ -814,6 +945,17 @@ export const useCippUserActions = () => { displayName: 'displayName', type: '!User', }, + // Pre-select the current source of authority (onPremisesSyncEnabled: true means + // on-premises managed; null/false means cloud managed); leave unselected when + // the selected rows have mixed states + defaultvalues: (row) => { + const states = [ + ...new Set( + (Array.isArray(row) ? row : [row]).map((r) => r?.onPremisesSyncEnabled === true) + ), + ] + return states.length === 1 ? { isCloudManaged: String(!states[0]) } : {} + }, fields: [ { type: 'radio', @@ -823,12 +965,35 @@ export const useCippUserActions = () => { { label: 'Cloud Managed', value: true }, { label: 'On-Premises Managed', value: false }, ], - validators: { required: 'Please select a source of authority' }, + validators: { + required: 'Please select a source of authority', + validate: (value, formValues, row) => { + const states = [ + ...new Set( + (Array.isArray(row) ? row : [row]).map((r) => r?.onPremisesSyncEnabled === true) + ), + ] + if (states.length === 1 && String(value) === String(!states[0])) { + return 'Source of authority is unchanged' + } + return true + }, + }, }, ], confirmText: 'Are you sure you want to change the source of authority for [userPrincipalName]? Setting it to On-Premises Managed will take until the next sync cycle to show the change.', multiPost: false, + // Only meaningful for users that are on-premises managed (convert to cloud) or + // were synced at some point (revert to on-premises); hide for cloud-native users + condition: (row) => + row?.onPremisesSyncEnabled === true || + !!( + row?.onPremisesImmutableId || + row?.OnPremisesImmutableId || + row?.onPremisesLastSyncDateTime || + row?.onPremisesDistinguishedName + ), }, { label: 'Reprocess License Assignments', 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/CippFormPages/CippAddEditUser.jsx b/src/components/CippFormPages/CippAddEditUser.jsx index 15a5a52782c1..a936c22b168c 100644 --- a/src/components/CippFormPages/CippAddEditUser.jsx +++ b/src/components/CippFormPages/CippAddEditUser.jsx @@ -71,6 +71,25 @@ const CippAddEditUser = (props) => { return [] }, [manualEntryMappings.isSuccess, manualEntryMappings.data]) + // Prefill manual entry custom data fields in edit mode. The fetched user's extension values sit + // at the top level of the form (edit.jsx resets with the spread user object), while these fields + // live under customData.* + const currentUserObjectId = useWatch({ control: formControl.control, name: 'id' }) + useEffect(() => { + if (formType === 'add' || !currentUserObjectId || currentTenantManualMappings.length === 0) + return + currentTenantManualMappings.forEach((mapping) => { + const attribute = mapping.customDataAttribute?.value + if (!attribute) return + const existing = formControl.getValues(`customData.${attribute}`) + if (existing !== undefined && existing !== null && existing !== '') return + const value = formControl.getValues(attribute) + if (value !== undefined && value !== null) { + formControl.setValue(`customData.${attribute}`, value) + } + }) + }, [formType, currentUserObjectId, currentTenantManualMappings]) + // Make new list of groups by removing userGroups from tenantGroups const filteredTenantGroups = useMemo(() => { if (tenantGroups.isSuccess && userGroups.isSuccess) { @@ -318,7 +337,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 +849,7 @@ const CippAddEditUser = (props) => { value: group.id, addedFields: { groupType: group.groupType, + calculatedGroupType: group.calculatedGroupType, }, })) || [] } @@ -914,65 +939,59 @@ const CippAddEditUser = (props) => { })} )} - {/* Schedule User Creation */} - {formType === 'add' && ( - <> - - - - - - - - - - - - - - - - - - - - )} + {/* Schedule User Creation / Edit */} + <> + + + + + + + + + + + + + + + + + + +
    ) } diff --git a/src/components/CippFormPages/CippAddGroupForm.jsx b/src/components/CippFormPages/CippAddGroupForm.jsx index 6ce4b08ce3bc..1af49a1a64e4 100644 --- a/src/components/CippFormPages/CippAddGroupForm.jsx +++ b/src/components/CippFormPages/CippAddGroupForm.jsx @@ -131,6 +131,33 @@ const CippAddGroupForm = (props) => { /> + + + + + + + + { /> + + + + + + + + { "Not Available" ), }, + { + label: "MCP API URL", + value: azureConfig.data?.Results?.ApiUrl ? ( + <> + + + + + + ) : ( + "Not Available" + ), + }, { label: "Token URL", value: azureConfig.data?.Results?.TenantID ? ( 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}> - + { ) : ( - + { Brand Color - - formControl.setValue("colour", e.target.value)} - style={{ - width: "50px", - height: "40px", - border: "1px solid #ddd", - borderRadius: "4px", - cursor: "pointer", - }} - /> - - + This color will be used for accents and highlights in reports diff --git a/src/components/CippSettings/CippSSOSettings.jsx b/src/components/CippSettings/CippSSOSettings.jsx index 5499b4e694d1..cc944882da8e 100644 --- a/src/components/CippSettings/CippSSOSettings.jsx +++ b/src/components/CippSettings/CippSSOSettings.jsx @@ -1,5 +1,8 @@ import { useEffect } from "react"; import { + Accordion, + AccordionDetails, + AccordionSummary, Alert, Button, CardActions, @@ -10,6 +13,7 @@ import { Stack, Typography, } from "@mui/material"; +import { ExpandMore } from "@mui/icons-material"; import { useForm } from "react-hook-form"; import { Grid } from "@mui/system"; import CippFormComponent from "../CippComponents/CippFormComponent"; @@ -34,6 +38,13 @@ export const CippSSOSettings = () => { defaultValues: { multiTenant: false }, }); + // Separate form for the manual-configuration section so its fields don't + // interfere with the automated flow's multiTenant switch. + const manualFormControl = useForm({ + mode: "onChange", + defaultValues: { appId: "", appSecret: "", multiTenant: false }, + }); + const ssoStatus = ApiGetCall({ url: "/api/ExecSSOSetup", data: { Action: "Status" }, @@ -48,6 +59,13 @@ export const CippSSOSettings = () => { if (ssoStatus.isSuccess && ssoStatus.data?.Results) { const data = ssoStatus.data.Results; formControl.reset({ multiTenant: data.multiTenant ?? false }); + // Seed the manual section's multi-tenant switch and App ID with the current + // values; leave the secret blank so it's only written when the admin types one. + manualFormControl.reset({ + appId: data.appId ?? "", + appSecret: "", + multiTenant: data.multiTenant ?? false, + }); } }, [ssoStatus.isSuccess, ssoStatus.data]); @@ -136,6 +154,36 @@ export const CippSSOSettings = () => { }); }; + const handleManualSave = manualFormControl.handleSubmit((values) => { + if ( + !window.confirm( + "This will overwrite the stored SSO Application ID and client secret in Key Vault. " + + "An incorrect App ID or secret will break single sign-on. Make sure the values are correct before continuing. Continue?" + ) + ) { + return; + } + ssoAction.mutate( + { + url: "/api/ExecSSOSetup", + data: { + Action: "ManualConfigure", + appId: values.appId?.trim(), + appSecret: values.appSecret, + multiTenant: values.multiTenant, + }, + }, + { + onSuccess: () => { + // Clear the secret field so it isn't left sitting in the form. On a CIPP-NG + // instance the returned message tells the user to restart to apply the change; + // the restart itself is done from the container-management page. + manualFormControl.setValue("appSecret", ""); + }, + } + ); + }); + return ( @@ -233,6 +281,65 @@ export const CippSSOSettings = () => { /> + + + } sx={{ px: 0 }}> + + Manual configuration (advanced) + + + + + + Enter an existing Application (client) ID and client secret to store them directly + in Key Vault โ€” for example to rotate the secret by hand or point SSO at a different + app registration. The instance must be restarted for the change to take effect. + + + + + + + + + + + + + + )} diff --git a/src/components/CippTable/CIPPTableToptoolbar.js b/src/components/CippTable/CIPPTableToptoolbar.js index 232c752bb2c9..cd101998ab4b 100644 --- a/src/components/CippTable/CIPPTableToptoolbar.js +++ b/src/components/CippTable/CIPPTableToptoolbar.js @@ -762,7 +762,7 @@ export const CIPPTableToptoolbar = React.memo( diff --git a/src/components/CippTable/CippDataTable.js b/src/components/CippTable/CippDataTable.js index 1d902eb64f90..f140fab281af 100644 --- a/src/components/CippTable/CippDataTable.js +++ b/src/components/CippTable/CippDataTable.js @@ -81,6 +81,10 @@ 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) @@ -328,7 +332,7 @@ function renderColumnFilterModeMenuItemsFn({ internalFilterOptions, onSelectFilt export const CippDataTable = (props) => { const { queryKey, - data = [], + data = EMPTY_ARRAY, columns = [], api = {}, isFetching = false, @@ -730,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 @@ -744,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/CippWizardGroupTemplates.jsx b/src/components/CippWizard/CippWizardGroupTemplates.jsx index 445043dff727..837d2fba4a72 100644 --- a/src/components/CippWizard/CippWizardGroupTemplates.jsx +++ b/src/components/CippWizard/CippWizardGroupTemplates.jsx @@ -44,6 +44,12 @@ export const CippWizardGroupTemplates = (props) => { formControl.setValue("licenses", watcher.addedFields.licenses || [], { shouldValidate: true, }); + formControl.setValue("aliases", watcher.addedFields.aliases, { + shouldValidate: true, + }); + formControl.setValue('hideFromGAL', watcher.addedFields.hideFromGAL, { + shouldValidate: true, + }); console.log("Set membershipRules to:", watcher.addedFields.membershipRules); }, 100); @@ -75,6 +81,8 @@ export const CippWizardGroupTemplates = (props) => { allowExternal: "allowExternal", membershipRules: "membershipRules", licenses: "licenses", + aliases: "aliases", + hideFromGAL: "hideFromGAL", }, showRefresh: true, }} @@ -146,6 +154,32 @@ export const CippWizardGroupTemplates = (props) => { /> + + + + + + + + { const { postUrl, formControl, onPreviousStep, onNextStep, currentStep, lastStep } = props @@ -198,6 +199,55 @@ export const CippWizardVacationActions = (props) => { formControl={formControl} /> + + + + + + + Excluding a user from a CA policy allows sign-ins from anywhere. This option + closes that gap: at the start date a named location and a conditional access + policy named 'Travel Policy <users> - <start date> - <end + date>' are created, blocking sign-ins for the selected users from + every location except the travel destination. At the end date, the policy and + the named location are deleted automatically. + + + + ({ + value: Code, + label: Name, + }))} + formControl={formControl} + validators={{ + validate: (option) => { + if (!Array.isArray(option) || option.length === 0) { + return 'At least one travel destination country must be selected' + } + return true + }, + }} + required={true} + /> + + diff --git a/src/components/CippWizard/CippWizardVacationConfirmation.jsx b/src/components/CippWizard/CippWizardVacationConfirmation.jsx index 2b2604d85d5f..f011e7d0e208 100644 --- a/src/components/CippWizard/CippWizardVacationConfirmation.jsx +++ b/src/components/CippWizard/CippWizardVacationConfirmation.jsx @@ -41,7 +41,11 @@ export const CippWizardVacationConfirmation = (props) => { const handleSubmit = () => { if (values.enableCAExclusion) { const policies = Array.isArray(values.PolicyId) ? values.PolicyId : [values.PolicyId] - const policyData = policies.map((policy) => ({ + const createTravelPolicy = + values.createTravelPolicy && + Array.isArray(values.travelCountries) && + values.travelCountries.length > 0 + const policyData = policies.map((policy, index) => ({ tenantFilter, Users: values.Users, PolicyId: policy?.value ?? policy, @@ -51,6 +55,11 @@ export const CippWizardVacationConfirmation = (props) => { reference: values.reference || null, postExecution: values.postExecution || [], excludeLocationAuditAlerts: values.excludeLocationAuditAlerts || false, + // Only send the travel policy fields on the first request so the + // temporary policy is scheduled once, not once per selected CA policy + ...(index === 0 && createTravelPolicy + ? { CreateTravelPolicy: true, TravelCountries: values.travelCountries } + : {}), })) caExclusion.mutate({ url: '/api/ExecCAExclusion', @@ -252,6 +261,23 @@ export const CippWizardVacationConfirmation = (props) => { )} + {values.createTravelPolicy && ( +
    + + Temporary Travel Policy + + + Sign-ins restricted to:{' '} + {Array.isArray(values.travelCountries) && + values.travelCountries.length > 0 + ? values.travelCountries.map((c) => c.label || c.value).join(', ') + : 'Not set'} + + + The policy and named location are deleted at the end date + +
    + )} diff --git a/src/components/ShadowAIReportButton.js b/src/components/ShadowAIReportButton.js new file mode 100644 index 000000000000..7d0c3be36305 --- /dev/null +++ b/src/components/ShadowAIReportButton.js @@ -0,0 +1,1395 @@ +import { useMemo, useState } from 'react' +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Paper, + Stack, + SvgIcon, + Switch, + Tooltip, + Typography, +} from '@mui/material' +import { Close, Download, PictureAsPdf, Settings } from '@mui/icons-material' +import { + Document, + Font, + Image, + Page, + Path, + PDFViewer, + StyleSheet, + Svg, + Text, + View, +} from '@react-pdf/renderer' +import { useSettings } from '../hooks/use-settings' + +// react-pdf hyphenates words by default ("Detected" becomes "De-tected" in narrow stat cards); +// keep words whole and let them wrap instead. +Font.registerHyphenationCallback((word) => [word]) + +// Executive Shadow AI report following the same design system as the executive report: +// full-bleed cover, branded page headers, black infographic chapter splitters, and data pages. +const ShadowAIReportDocument = ({ + tenantName, + data, + brandingSettings, + sectionConfig = { + coverPage: true, + executiveSummary: true, + infographics: true, + background: true, + riskLevels: true, + sanctionedTools: true, + detectedSoftware: true, + entraApplications: true, + recommendations: true, + }, +}) => { + const currentDate = new Date().toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }) + const brandColor = brandingSettings?.colour || '#F77F00' + + const summary = data?.summary ?? {} + const detectedApps = data?.detectedApps ?? [] + const consentedApps = data?.consentedApps ?? [] + const topTools = data?.topTools ?? [] + const byRisk = data?.byRisk ?? [] + + const styles = StyleSheet.create({ + page: { + flexDirection: 'column', + backgroundColor: '#FFFFFF', + fontFamily: 'Helvetica', + fontSize: 10, + lineHeight: 1.4, + color: '#2D3748', + padding: 40, + paddingBottom: 60, + }, + + // COVER PAGE + coverPage: { + flexDirection: 'column', + backgroundColor: '#FFFFFF', + fontFamily: 'Helvetica', + padding: 60, + justifyContent: 'space-between', + minHeight: '100%', + }, + coverHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 80, + }, + logo: { + height: 100, + marginRight: 12, + }, + headerLogo: { + height: 30, + }, + dateStamp: { + fontSize: 9, + color: '#000000', + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + coverHero: { + flex: 1, + justifyContent: 'flex-start', + alignItems: 'flex-start', + paddingTop: 40, + }, + coverLabel: { + backgroundColor: brandColor, + color: '#FFFFFF', + fontSize: 10, + fontWeight: 'bold', + textTransform: 'uppercase', + letterSpacing: 1, + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 20, + marginBottom: 30, + alignSelf: 'flex-start', + }, + mainTitle: { + fontSize: 48, + fontWeight: 'bold', + color: '#1A202C', + lineHeight: 1.1, + marginBottom: 20, + letterSpacing: -1, + textTransform: 'uppercase', + }, + titleAccent: { + color: brandColor, + }, + subtitle: { + fontSize: 14, + color: '#000000', + fontWeight: 'normal', + lineHeight: 1.5, + marginBottom: 40, + maxWidth: 400, + }, + tenantName: { + fontSize: 18, + fontWeight: 'bold', + color: '#000000', + marginBottom: 8, + }, + coverFooter: { + textAlign: 'center', + marginTop: 60, + }, + confidential: { + fontSize: 9, + color: '#A0AEC0', + textTransform: 'uppercase', + letterSpacing: 1, + }, + + // CONTENT PAGES + pageHeader: { + borderBottom: `1px solid ${brandColor}`, + paddingBottom: 12, + marginBottom: 24, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + }, + pageHeaderContent: { + flex: 1, + }, + pageTitle: { + fontSize: 20, + fontWeight: 'bold', + color: '#1A202C', + marginBottom: 8, + }, + pageSubtitle: { + fontSize: 11, + color: '#4A5568', + fontWeight: 'normal', + }, + section: { + marginBottom: 24, + }, + sectionTitle: { + fontSize: 14, + fontWeight: 'bold', + color: brandColor, + marginBottom: 12, + }, + bodyText: { + fontSize: 9, + color: '#2D3748', + lineHeight: 1.5, + marginBottom: 12, + textAlign: 'justify', + }, + + // STATS GRID + statsGrid: { + flexDirection: 'row', + gap: 12, + marginBottom: 20, + }, + statCard: { + flex: 1, + backgroundColor: '#FFFFFF', + border: `1px solid #E2E8F0`, + borderRadius: 6, + padding: 16, + alignItems: 'center', + borderTop: `3px solid ${brandColor}`, + }, + statNumber: { + fontSize: 16, + fontWeight: 'bold', + color: brandColor, + marginBottom: 4, + }, + statLabel: { + fontSize: 7, + color: '#4A5568', + textTransform: 'uppercase', + letterSpacing: 0.5, + textAlign: 'center', + fontWeight: 'bold', + }, + + // TABLES + controlsTable: { + border: `1px solid #E2E8F0`, + borderRadius: 6, + overflow: 'hidden', + }, + tableHeader: { + flexDirection: 'row', + backgroundColor: brandColor, + paddingVertical: 10, + paddingHorizontal: 12, + }, + headerCell: { + fontSize: 7, + fontWeight: 'bold', + color: '#FFFFFF', + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + tableRow: { + flexDirection: 'row', + borderBottomWidth: 1, + borderBottomColor: '#F7FAFC', + paddingVertical: 8, + paddingHorizontal: 12, + alignItems: 'center', + }, + cellName: { + fontSize: 8, + fontWeight: 'bold', + color: '#2D3748', + }, + cellDesc: { + fontSize: 7, + color: '#4A5568', + lineHeight: 1.3, + }, + + // INFO BOXES + infoBox: { + backgroundColor: '#FFFFFF', + border: `1px solid #E2E8F0`, + borderLeft: `4px solid ${brandColor}`, + borderRadius: 4, + padding: 12, + marginBottom: 12, + }, + infoTitle: { + fontSize: 9, + fontWeight: 'bold', + color: '#2D3748', + marginBottom: 6, + }, + infoText: { + fontSize: 8, + color: '#4A5568', + lineHeight: 1.4, + }, + + // RECOMMENDATIONS + recommendationsList: { + gap: 8, + }, + recommendationItem: { + flexDirection: 'row', + alignItems: 'flex-start', + }, + recommendationBullet: { + fontSize: 8, + color: brandColor, + marginRight: 6, + fontWeight: 'bold', + marginTop: 1, + }, + recommendationText: { + fontSize: 8, + color: '#2D3748', + lineHeight: 1.4, + flex: 1, + }, + recommendationLabel: { + fontWeight: 'bold', + }, + + // CHART + chartContainer: { + backgroundColor: '#FFFFFF', + border: `1px solid #E2E8F0`, + borderRadius: 6, + padding: 16, + marginBottom: 20, + alignItems: 'center', + }, + chartTitle: { + fontSize: 10, + fontWeight: 'bold', + color: '#2D3748', + marginBottom: 12, + }, + svgChart: { + width: 400, + height: 180, + marginBottom: 8, + }, + legendRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 4, + alignSelf: 'flex-start', + }, + legendSwatch: { + width: 8, + height: 8, + borderRadius: 4, + marginRight: 6, + }, + legendText: { + fontSize: 8, + color: '#4A5568', + }, + + // FOOTER + footer: { + position: 'absolute', + bottom: 20, + left: 40, + right: 40, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + borderTop: '1px solid #E2E8F0', + paddingTop: 8, + }, + footerText: { + fontSize: 7, + color: '#718096', + }, + pageNumber: { + fontSize: 7, + color: '#718096', + fontWeight: 'bold', + }, + + // BLACK STATISTIC PAGES + statPage: { + flexDirection: 'column', + backgroundColor: '#000000', + fontFamily: 'Helvetica', + padding: 0, + justifyContent: 'center', + alignItems: 'flex-start', + minHeight: '100%', + position: 'relative', + }, + statBackground: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + opacity: 0.5, + }, + statOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + padding: 60, + justifyContent: 'center', + alignItems: 'flex-start', + zIndex: 10, + backgroundColor: 'rgba(0, 0, 0, 0.7)', + }, + statHighlight: { + fontSize: 72, + color: brandColor, + fontWeight: '900', + lineHeight: 1, + marginBottom: 8, + }, + statSubText: { + fontSize: 14, + color: '#FFFFFF', + fontWeight: 'bold', + lineHeight: 1.3, + marginBottom: 40, + }, + statFooterText: { + position: 'absolute', + bottom: 60, + right: 60, + fontSize: 12, + color: '#FFFFFF', + fontWeight: 'bold', + textAlign: 'right', + lineHeight: 1.3, + }, + }) + + const PageFooter = () => ( + + {tenantName} - Shadow AI Report + `Page ${pageNumber} of ${totalPages}`} + /> + + ) + + const ContentPageHeader = ({ title, subtitle }) => ( + + + {title} + {subtitle} + + {brandingSettings?.logo && ( + + )} + + ) + + const riskColors = { + high: '#EF4444', + medium: '#F59E0B', + low: '#3B82F6', + informational: '#10B981', + } + const riskColor = (risk) => riskColors[String(risk).toLowerCase()] ?? '#A0AEC0' + + // Donut chart for the risk distribution, same construction as the drift donut in the + // executive report. + const RiskDonut = () => { + const chartData = byRisk.filter((item) => item.tools > 0) + const total = chartData.reduce((sum, item) => sum + item.tools, 0) + if (total === 0) return null + + const centerX = 200 + const centerY = 90 + const outerRadius = 60 + const innerRadius = 25 + + let currentAngle = 0 + const slices = chartData.map((item) => { + // Cap a full-circle slice just below 360 degrees - an arc with identical start and + // end points renders as nothing. + const angle = Math.min((item.tools / total) * 360, 359.99) + const startAngle = currentAngle + const endAngle = currentAngle + angle + currentAngle = endAngle + + const outerStartX = centerX + outerRadius * Math.cos((startAngle * Math.PI) / 180) + const outerStartY = centerY + outerRadius * Math.sin((startAngle * Math.PI) / 180) + const outerEndX = centerX + outerRadius * Math.cos((endAngle * Math.PI) / 180) + const outerEndY = centerY + outerRadius * Math.sin((endAngle * Math.PI) / 180) + const innerStartX = centerX + innerRadius * Math.cos((startAngle * Math.PI) / 180) + const innerStartY = centerY + innerRadius * Math.sin((startAngle * Math.PI) / 180) + const innerEndX = centerX + innerRadius * Math.cos((endAngle * Math.PI) / 180) + const innerEndY = centerY + innerRadius * Math.sin((endAngle * Math.PI) / 180) + const largeArcFlag = angle > 180 ? 1 : 0 + + const pathData = [ + `M ${outerStartX} ${outerStartY}`, + `A ${outerRadius} ${outerRadius} 0 ${largeArcFlag} 1 ${outerEndX} ${outerEndY}`, + `L ${innerEndX} ${innerEndY}`, + `A ${innerRadius} ${innerRadius} 0 ${largeArcFlag} 0 ${innerStartX} ${innerStartY}`, + 'Z', + ].join(' ') + + return { pathData, color: riskColor(item.risk) } + }) + + return ( + + AI Tool Risk Distribution + + {slices.map((slice, index) => ( + + ))} + + {chartData.map((item) => ( + + + + {item.risk}: {item.tools} {item.tools === 1 ? 'tool' : 'tools'} + + + ))} + + ) + } + + const riskAreas = [ + { + title: 'Data Leakage', + color: '#EF4444', + text: 'Customer records, credentials, source code and financials pasted into consumer AI tools may be retained by the provider and used to train future models, permanently placing them outside your control. Every prompt is a data transfer to a third party.', + }, + { + title: 'Compliance & Legal Exposure', + color: '#F59E0B', + text: 'Processing personal data through unvetted AI services can breach GDPR, HIPAA and industry-specific regulations, and undermines contractual confidentiality commitments made to customers.', + }, + { + title: 'Excessive Application Permissions', + color: '#D69E2E', + text: 'AI meeting assistants and productivity plugins often request broad access to mailboxes, calendars and files. A single user consent can expose organization-wide data to a third-party service, and that access persists until the consent is revoked.', + }, + { + title: 'Unreliable Output in Business Processes', + color: '#3B82F6', + text: 'AI-generated content flows into quotes, contracts and customer communication without review. Errors produced by unmanaged tools are difficult to trace because the organization does not know the tools are in use.', + }, + ] + + const riskLevels = [ + { + name: 'High', + text: 'Consumer tools that train on submitted data, retain prompts indefinitely, or operate from jurisdictions without adequate data protection. Content pasted into these tools should be treated as disclosed to an unvetted third party.', + }, + { + name: 'Medium', + text: 'Tools with business-grade privacy options that are typically used through personal, unmanaged accounts. Risk depends heavily on the plan and account type in use.', + }, + { + name: 'Low', + text: 'Tools with enterprise controls, contractual data-processing terms and no training on customer data when configured correctly.', + }, + { + name: 'Informational', + text: 'Company sanctioned tools that have been explicitly approved for use in this tenant. They remain in the report for visibility but no longer contribute to the risk figures.', + }, + ] + + const recommendations = [ + { + label: 'Review & Decide:', + text: 'Evaluate each detected tool with stakeholders and decide whether it should be sanctioned, replaced with an approved alternative, or blocked.', + }, + { + label: 'Sanction Approved Tools:', + text: 'Maintain a list of company sanctioned tools so future reports separate approved AI use from true shadow AI.', + }, + { + label: 'Offer an Alternative:', + text: 'Provide a sanctioned option such as Microsoft 365 Copilot before blocking popular tools - blocking without an alternative drives usage to personal devices.', + }, + { + label: 'Restrict Consent:', + text: 'Require admin approval for unverified applications in Entra ID so new AI services cannot access company data through user consent.', + }, + { + label: 'Block & Monitor:', + text: 'Deploy Conditional Access and Defender for Cloud Apps policies to block or monitor unsanctioned AI web services.', + }, + { + label: 'Extend DLP:', + text: 'Cover generative AI endpoints with data loss prevention policies to stop sensitive data from being pasted into chat prompts.', + }, + { + label: 'Train Users:', + text: 'Publish an acceptable AI use policy and train users on what data may never be shared with AI tools.', + }, + { + label: 'Review Monthly:', + text: 'Re-run this report on a regular cadence - new AI tools appear in tenants within days of release.', + }, + ] + + const detectedRows = detectedApps.slice(0, 18) + const consentedRows = consentedApps.slice(0, 18) + + // Distinct sanctioned tools across both sources, with their footprint + const sanctionedMap = {} + for (const app of detectedApps) { + if (app.status !== 'Sanctioned') continue + if (!sanctionedMap[app.aiTool]) { + sanctionedMap[app.aiTool] = { + tool: app.aiTool, + vendor: app.vendor, + category: app.category, + devices: 0, + users: 0, + } + } + sanctionedMap[app.aiTool].devices += app.deviceCount ?? 0 + } + for (const app of consentedApps) { + if (app.status !== 'Sanctioned') continue + if (!sanctionedMap[app.aiTool]) { + sanctionedMap[app.aiTool] = { + tool: app.aiTool, + vendor: app.vendor, + category: app.category, + devices: 0, + users: 0, + } + } + sanctionedMap[app.aiTool].users += app.activeUsersLast7Days ?? 0 + } + const sanctionedToolsList = Object.values(sanctionedMap).slice(0, 18) + + return ( + + {/* COVER PAGE */} + {sectionConfig.coverPage && ( + + + + + {brandingSettings?.logo && ( + + )} + + {currentDate} + + + + AI RISK ASSESSMENT + + + Shadow AI{'\n'} + Report + + + + Discovery and risk assessment of AI tools in use across managed devices and cloud + applications + + + {tenantName || 'Organization Name'} + + + + Confidential & Proprietary + + + )} + + {/* EXECUTIVE SUMMARY */} + {sectionConfig.executiveSummary && ( + + + + + + This report identifies the artificial intelligence tools discovered in the{' '} + {tenantName || 'your organization'}{' '} + environment, combining software inventory from managed devices (Intune) with cloud + application consent data from Entra ID. Each tool is matched against a curated catalog + of known AI services and assigned a risk level based on its data handling practices. + + + Tools that have been explicitly approved are marked as company sanctioned and report + the Informational risk level. Everything else represents shadow AI: tools adopted by + employees without review or approval, whose handling of company data is unknown. + + + + + AI Usage Overview + + + {summary.aiToolsDetected ?? 0} + AI Tools + + + {summary.deviceInstalls ?? 0} + Device Installs + + + {summary.consentedAiApps ?? 0} + Entra AI Apps + + + + {summary.highRiskTools ?? 0} + + High Risk + + + + {summary.sanctionedTools ?? 0} + + Sanctioned + + + + + {topTools.length > 0 && ( + + Most Used AI Tools + + + Tool + Category + Status + Devices + Users (7d) + + {topTools.map((tool, index) => ( + + {tool.tool} + + {tool.category} + + + {tool.status ?? 'Unsanctioned'} + + + {tool.devices} + + + {tool.users} + + + ))} + + + )} + + + + )} + + {/* STATISTIC PAGE - CHAPTER SPLITTER */} + {sectionConfig.infographics && ( + + + + 75% + + of knowledge workers already{'\n'} + use generative AI at work -{'\n'} + most without their employer knowing + + + + Visibility is the first step{'\n'} + to control + + + )} + + {/* UNDERSTANDING SHADOW AI */} + {sectionConfig.background && ( + + + + + + Shadow AI is the use of artificial intelligence tools by employees without the + knowledge or approval of the organization - the AI-era equivalent of shadow IT. + Because most AI tools are free, browser-based and immediately useful, adoption happens + quietly and quickly: an employee pastes a customer email into a chatbot to draft a + reply, uploads a spreadsheet for analysis, or installs an AI notetaker that joins + every meeting. + + + The goal of a shadow AI program is not zero AI usage, but zero unsanctioned usage. + Every tool in this report should end up either approved and managed, or replaced and + blocked. The four risk areas below explain why unmanaged usage deserves attention. + + + + + Key Risk Areas + {riskAreas.map((area) => ( + + {area.title} + {area.text} + + ))} + + + + + )} + + {/* RISK LEVELS & DISTRIBUTION */} + {sectionConfig.riskLevels && ( + + + + + + Detected tools are matched against a curated catalog of known AI services, each + carrying a risk classification based on its data handling practices, account model and + enterprise controls. Marking a tool as company sanctioned overrides its catalog risk + with the Informational level, so the figures below reflect only unapproved use. + + + + + + + {[0, 2].map((start) => ( + + {riskLevels.slice(start, start + 2).map((level) => ( + + + {level.name} + + {level.text} + + ))} + + ))} + + + + + )} + + {/* SANCTIONED TOOLS */} + {sectionConfig.sanctionedTools && sanctionedToolsList.length > 0 && ( + + + + + + The tools listed below are permitted in this environment. They are allowed either + because a business justification exists for their use, or because the system + administrator has explicitly approved these tools for deployment. Sanctioned tools + report the Informational risk level and are excluded from the shadow AI risk figures + in this report; they remain listed for visibility into where AI is used across the + organization. + + + Approval is not permanent: sanctioned tools should be reviewed periodically to confirm + that the plan in use, the vendor's data handling terms and the business justification + still hold. + + + + + + + Tool + Vendor + Category + Devices + Users (7d) + + {sanctionedToolsList.map((tool, index) => ( + + {tool.tool} + {tool.vendor} + {tool.category} + {tool.devices} + {tool.users} + + ))} + + + + + + )} + + {/* DETECTED SOFTWARE */} + {sectionConfig.detectedSoftware && ( + + + + + + The following AI applications were detected in the software inventory of managed + devices + {detectedApps.length > detectedRows.length + ? `, showing the top ${detectedRows.length} of ${detectedApps.length} entries` + : ''} + . Device counts indicate how widely each application has spread through the + environment. + + + + + {detectedRows.length === 0 ? ( + + No AI software was detected on managed devices during the last inventory sync. + + ) : ( + + + Application + AI Tool + Category + Risk + Status + Devices + + {detectedRows.map((app, index) => ( + + {app.application} + {app.aiTool} + + {app.category} + + + {app.risk} + + + {app.status} + + + {app.deviceCount} + + + ))} + + )} + + + + + )} + + {/* ENTRA APPLICATIONS */} + {sectionConfig.entraApplications && ( + + + + + + The following AI services are registered as applications in the tenant, including any + permissions users have consented to + {consentedApps.length > consentedRows.length + ? `, showing the top ${consentedRows.length} of ${consentedApps.length} entries` + : ''} + . The consent date shows when each service first gained a foothold in the environment. + + + + + {consentedRows.length === 0 ? ( + No AI applications were found in Entra ID. + ) : ( + + + Application + AI Tool + Risk + Status + Users (7d) + + First Consented + + + {consentedRows.map((app, index) => ( + + {app.application} + {app.aiTool} + + {app.risk} + + + {app.status} + + + {app.activeUsersLast7Days} + + + {app.firstConsentedDateTime + ? new Date(app.firstConsentedDateTime).toLocaleDateString() + : 'Unknown'} + + + ))} + + )} + + + + + )} + + {/* STATISTIC PAGE - CHAPTER SPLITTER */} + {sectionConfig.infographics && ( + + + + 1 in 3 + + employees shares sensitive work data + {'\n'} + with AI tools without approval + + + + Sanctioned alternatives keep{'\n'} + your data under contract + + + )} + + {/* RECOMMENDATIONS */} + {sectionConfig.recommendations && ( + + + + + + A structured response to shadow AI combines approval of useful tools with controls on + the rest. The following actions are recommended based on the findings in this report: + + + + + Action Plan + + {recommendations.map((recommendation, index) => ( + + โ€ข + + {recommendation.label}{' '} + {recommendation.text} + + + ))} + + + + + + Next Review + + The AI tool landscape changes quickly and new tools appear in tenants within days of + release. We recommend re-running this assessment monthly and reviewing newly + detected tools against your acceptable AI use policy. + + + + + + + )} + + ) +} + +const sectionOptions = [ + { + key: 'coverPage', + label: 'Cover Page', + description: 'Branded title page with tenant name and date', + }, + { + key: 'executiveSummary', + label: 'Executive Summary', + description: 'High-level overview, usage statistics and top tools', + }, + { + key: 'infographics', + label: 'Infographic Pages', + description: 'Statistical pages with visual elements between sections', + }, + { + key: 'background', + label: 'Understanding Shadow AI', + description: 'Explains shadow AI and its key risk areas', + }, + { + key: 'riskLevels', + label: 'Risk Levels & Distribution', + description: 'Risk methodology and distribution chart', + }, + { + key: 'sanctionedTools', + label: 'Sanctioned Tools', + description: 'Company approved AI tools and their footprint', + }, + { + key: 'detectedSoftware', + label: 'AI Software (Intune)', + description: 'AI applications found on managed devices', + }, + { + key: 'entraApplications', + label: 'AI Applications (Entra)', + description: 'AI services with consented permissions in Entra ID', + }, + { + key: 'recommendations', + label: 'Recommendations', + description: 'Action plan for managing shadow AI', + }, +] + +export const ShadowAIReportButton = ({ data, tenantName, disabled }) => { + const settings = useSettings() + const brandingSettings = settings.customBranding + const [previewOpen, setPreviewOpen] = useState(false) + const [sectionConfig, setSectionConfig] = useState({ + coverPage: true, + executiveSummary: true, + infographics: true, + background: true, + riskLevels: true, + sanctionedTools: true, + detectedSoftware: true, + entraApplications: true, + recommendations: true, + }) + + const handleSectionToggle = (sectionKey) => { + setSectionConfig((prev) => { + const enabledSections = Object.values(prev).filter(Boolean).length + // Keep at least one section enabled + if (prev[sectionKey] && enabledSections === 1) { + return prev + } + return { ...prev, [sectionKey]: !prev[sectionKey] } + }) + } + + const fileName = `Shadow_AI_Report_${String(tenantName).replace(/[^a-zA-Z0-9]/g, '_')}_${ + new Date().toISOString().split('T')[0] + }.pdf` + + const reportDocument = useMemo(() => { + if (!previewOpen) return null + return ( + + ) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [previewOpen, tenantName, data, brandingSettings, JSON.stringify(sectionConfig)]) + + return ( + <> + + + + + + + setPreviewOpen(false)} + maxWidth="xl" + fullWidth + sx={{ '& .MuiDialog-paper': { height: '95vh', maxHeight: '95vh' } }} + > + + + Shadow AI Report - {tenantName} + + setPreviewOpen(false)} size="small"> + + + + + {/* Left Panel - Section Configuration */} + + + + + Report Sections + + + Configure which sections to include in your Shadow AI report. Changes are reflected + in real-time. + + + + {sectionOptions.map((option) => ( + handleSectionToggle(option.key)} + sx={{ + p: 1.5, + border: '1px solid', + borderColor: sectionConfig[option.key] ? 'primary.main' : 'divider', + bgcolor: sectionConfig[option.key] ? 'primary.50' : 'background.paper', + cursor: 'pointer', + transition: 'all 0.2s ease-in-out', + display: 'flex', + alignItems: 'center', + '&:hover': { + borderColor: 'primary.main', + bgcolor: sectionConfig[option.key] ? 'primary.100' : 'primary.25', + }, + }} + > + { + event.stopPropagation() + handleSectionToggle(option.key) + }} + onClick={(event) => event.stopPropagation()} + color="primary" + size="small" + disabled={ + sectionConfig[option.key] && + Object.values(sectionConfig).filter(Boolean).length === 1 + } + /> + + + {option.label} + + + {option.description} + + + + ))} + + + + + {/* Right Panel - PDF Preview */} + + {reportDocument && ( + + {reportDocument} + + )} + + + + + + Sections enabled: {Object.values(sectionConfig).filter(Boolean).length} of{' '} + {sectionOptions.length} + + + + + + + + + ) +} diff --git a/src/components/csvExportButton.js b/src/components/csvExportButton.js index 0a05aa64fbe6..3b619defc197 100644 --- a/src/components/csvExportButton.js +++ b/src/components/csvExportButton.js @@ -2,6 +2,7 @@ import { BackupTableTwoTone } from '@mui/icons-material' import { IconButton, Tooltip } from '@mui/material' import { mkConfig, generateCsv, download } from 'export-to-csv' import { getCippFormatting } from '../utils/get-cipp-formatting' +import { SKIP_RECURSION_KEYS } from '../utils/skip-recursion-keys' const csvConfig = mkConfig({ fieldSeparator: ',', decimalSeparator: '.', @@ -13,7 +14,12 @@ const flattenObject = (obj, parentKey = '') => { const flattened = {} Object.keys(obj).forEach((key) => { const fullKey = parentKey ? `${parentKey}.${key}` : key - if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) { + if ( + typeof obj[key] === 'object' && + obj[key] !== null && + !Array.isArray(obj[key]) && + !SKIP_RECURSION_KEYS.includes(key) + ) { Object.assign(flattened, flattenObject(obj[key], fullKey)) } else if (Array.isArray(obj[key]) && typeof obj[key][0] === 'string') { flattened[fullKey] = obj[key] diff --git a/src/components/pdfExportButton.js b/src/components/pdfExportButton.js index 93b415d9908f..9d49dc17cf27 100644 --- a/src/components/pdfExportButton.js +++ b/src/components/pdfExportButton.js @@ -1,6 +1,7 @@ import { IconButton, Tooltip } from '@mui/material' import { PictureAsPdf } from '@mui/icons-material' import { getCippFormatting } from '../utils/get-cipp-formatting' +import { SKIP_RECURSION_KEYS } from '../utils/skip-recursion-keys' import { useSettings } from '../hooks/use-settings' // Flatten nested objects so deeply nested properties export properly. @@ -9,7 +10,12 @@ const flattenObject = (obj, parentKey = '') => { const flattened = {} Object.keys(obj).forEach((key) => { const fullKey = parentKey ? `${parentKey}.${key}` : key - if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) { + if ( + typeof obj[key] === 'object' && + obj[key] !== null && + !Array.isArray(obj[key]) && + !SKIP_RECURSION_KEYS.includes(key) + ) { Object.assign(flattened, flattenObject(obj[key], fullKey)) } else { // Store the raw value - formatting will happen in a single pass later diff --git a/src/contexts/settings-context.js b/src/contexts/settings-context.js index 156403b0c472..13771d55e1fe 100644 --- a/src/contexts/settings-context.js +++ b/src/contexts/settings-context.js @@ -83,6 +83,7 @@ const initialSettings = { pinNav: true, currentTenant: null, showDevtools: false, + showAdvancedTools: false, customBranding: { colour: "#F77F00", logo: null, diff --git a/src/data/AuditLogTemplates.json b/src/data/AuditLogTemplates.json index 68f87b52bdf6..3999de345b0f 100644 --- a/src/data/AuditLogTemplates.json +++ b/src/data/AuditLogTemplates.json @@ -446,5 +446,52 @@ } ] } + }, + { + "value": "Set-MailboxForwardingExternal", + "name": "External email forwarding has been configured on a mailbox", + "template": { + "preset": { + "value": "Set-MailboxForwardingExternal", + "label": "External email forwarding has been configured on a mailbox" + }, + "logbook": { "value": "Audit.Exchange", "label": "Exchange" }, + "conditions": [ + { + "Property": { "value": "List:Operation", "label": "Operation" }, + "Operator": { "value": "EQ", "label": "Equals to" }, + "Input": { + "value": "Set-Mailbox", + "label": "mailbox settings were changed" + } + }, + { + "Property": { "value": "String", "label": "ForwardingSmtpAddress" }, + "Operator": { "value": "like", "label": "Like" }, + "Input": { "value": "*@*" } + } + ] + } + }, + { + "value": "Add-RoleGroupMember", + "name": "A user has been granted Exchange admin privileges", + "template": { + "preset": { + "value": "Add-RoleGroupMember", + "label": "A user has been granted Exchange admin privileges" + }, + "logbook": { "value": "Audit.Exchange", "label": "Exchange" }, + "conditions": [ + { + "Property": { "value": "List:Operation", "label": "Operation" }, + "Operator": { "value": "EQ", "label": "Equals to" }, + "Input": { + "value": "Add-RoleGroupMember", + "label": "added a member to an Exchange role group" + } + } + ] + } } ] diff --git a/src/data/CIPPDBCacheTypes.json b/src/data/CIPPDBCacheTypes.json index f86f7d59b03d..5fe47cbf605a 100644 --- a/src/data/CIPPDBCacheTypes.json +++ b/src/data/CIPPDBCacheTypes.json @@ -329,6 +329,11 @@ "friendlyName": "Detected Apps", "description": "All detected applications with devices where each app is installed" }, + { + "type": "DefenderCVEs", + "friendlyName": "Defender CVEs", + "description": "All Defender CVEs for Devices" + }, { "type": "IntuneAppInstallStatus", "friendlyName": "Intune App Install Status", diff --git a/src/data/Extensions.json b/src/data/Extensions.json index 83872799e810..15f7da3a7c07 100644 --- a/src/data/Extensions.json +++ b/src/data/Extensions.json @@ -283,18 +283,6 @@ "name": "HaloPSA.Enabled", "label": "Enable Integration" }, - { - "type": "switch", - "name": "HaloPSA.ConsolidateTickets", - "label": "Consolidate Tickets", - "placeholder": "Consolidate tickets for the same alert into one ticket.", - "condition": { - "field": "HaloPSA.Enabled", - "compareType": "is", - "compareValue": true, - "action": "disable" - } - }, { "type": "textField", "name": "HaloPSA.ResourceURL", @@ -365,6 +353,8 @@ "name": "HaloPSA.TicketType", "label": "HaloPSA Ticket Type", "placeholder": "Select your HaloPSA Ticket Type, leave blank for default", + "helperText": "The ticket type used for CIPP alert tickets. Sets the workflow and the Outcomes available below. Save after changing so the Outcome list refreshes.", + "fullRow": true, "multiple": false, "api": { "url": "/api/ExecExtensionMapping", @@ -384,11 +374,25 @@ "action": "disable" } }, + { + "type": "switch", + "name": "HaloPSA.ConsolidateTickets", + "label": "Consolidate Tickets", + "helperText": "Adds repeat alerts (same title) as a note on the existing open ticket instead of raising a new one. Turns on the Outcome dropdown below, which needs a valid private note action.", + "condition": { + "field": "HaloPSA.Enabled", + "compareType": "is", + "compareValue": true, + "action": "disable" + } + }, { "type": "autoComplete", "name": "HaloPSA.Outcome", "label": "HaloPSA Outcome", "placeholder": "Select your HaloPSA Outcome, leave blank for default", + "helperText": "The action applied when a duplicate alert is added to an existing ticket. Use a private note action your HaloPSA API user can run. Set the Ticket Type first and save - the list only shows outcomes from that type's workflow.", + "fullRow": true, "multiple": false, "api": { "url": "/api/ExecExtensionMapping", @@ -405,6 +409,18 @@ "field": "HaloPSA.ConsolidateTickets", "compareType": "is", "compareValue": true, + "action": "hide" + } + }, + { + "type": "switch", + "name": "HaloPSA.LinkTicketsToUsers", + "label": "Link Tickets to affected Users", + "helperText": "Raises one ticket per affected user and links it to the matching HaloPSA contact (by Azure Object ID, then email/login). No match falls back to the client's General User, with the UPN in the ticket body.", + "condition": { + "field": "HaloPSA.Enabled", + "compareType": "is", + "compareValue": true, "action": "disable" } } @@ -504,6 +520,29 @@ "compareValue": true, "action": "disable" } + }, + { + "type": "switch", + "name": "NinjaOne.CveSyncEnabled", + "label": "Enable Automated CVE Sync", + "condition": { + "field": "NinjaOne.Enabled", + "compareType": "is", + "compareValue": true, + "action": "disable" + } + }, + { + "type": "textField", + "name": "NinjaOne.CveSyncPrefix", + "label": "CVE Sync Scan Group Prefix", + "placeholder": "CIPP-", + "helperText": "Scan groups will be named: [Prefix][tenant-domain] (e.g., CIPP-contoso.com)", + "condition": { + "field": "NinjaOne.CveSyncEnabled", + "compareType": "is", + "compareValue": true + } } ], "mappingRequired": true, diff --git a/src/data/GDAPRoles.json b/src/data/GDAPRoles.json index df827501cdeb..81f03e3da842 100644 --- a/src/data/GDAPRoles.json +++ b/src/data/GDAPRoles.json @@ -33,7 +33,7 @@ }, { "ExtensionData": {}, - "Description": "Assign custom security attribute keys and values to supported Azure AD objects.", + "Description": "Assign custom security attribute keys and values to supported Microsoft Entra objects.", "IsEnabled": true, "IsSystem": true, "Name": "Attribute Assignment Administrator", @@ -41,7 +41,7 @@ }, { "ExtensionData": {}, - "Description": "Read custom security attribute keys and values for supported Azure AD objects.", + "Description": "Read custom security attribute keys and values for supported Microsoft Entra objects.", "IsEnabled": true, "IsSystem": true, "Name": "Attribute Assignment Reader", @@ -105,10 +105,10 @@ }, { "ExtensionData": {}, - "Description": "Users assigned to this role are added to the local administrators group on Azure AD-joined devices.", + "Description": "Users assigned to this role are added to the local administrators group on Microsoft Entra joined devices.", "IsEnabled": true, "IsSystem": true, - "Name": "Azure AD Joined Device Local Administrator", + "Name": "Microsoft Entra Joined Device Local Administrator", "ObjectId": "9f06204d-73c1-4d4c-880a-6edb90606fd8" }, { @@ -169,7 +169,7 @@ }, { "ExtensionData": {}, - "Description": "Full access to manage devices in Azure AD.", + "Description": "Full access to manage devices in Microsoft Entra ID.", "IsEnabled": true, "IsSystem": true, "Name": "Cloud Device Administrator", @@ -177,15 +177,15 @@ }, { "ExtensionData": {}, - "Description": "Can manage all aspects of Azure AD and Microsoft services that use Azure AD identities. This role was formerly known as Global Administrator.", + "Description": "Can manage all aspects of Microsoft Entra ID and Microsoft services that use Microsoft Entra identities.", "IsEnabled": true, "IsSystem": true, - "Name": "Company Administrator", + "Name": "Global Administrator", "ObjectId": "62e90394-69f5-4237-9190-012177145e10" }, { "ExtensionData": {}, - "Description": "Can read and manage compliance configuration and reports in Azure AD and Microsoft 365.", + "Description": "Can read and manage compliance configuration and reports in Microsoft Entra ID and Microsoft 365.", "IsEnabled": true, "IsSystem": true, "Name": "Compliance Administrator", @@ -212,7 +212,7 @@ "Description": "Can approve Microsoft support requests to access customer organizational data.", "IsEnabled": true, "IsSystem": true, - "Name": "Customer LockBox Access Approver", + "Name": "Customer Lockbox Access Approver", "ObjectId": "5c4f9dcd-47dc-4cf7-8c9a-9e4207cbfc91" }, { @@ -249,7 +249,7 @@ }, { "ExtensionData": {}, - "Description": "Only used by Azure AD Connect service.", + "Description": "Only used by Microsoft Entra Connect service.", "IsEnabled": true, "IsSystem": true, "Name": "Directory Synchronization Accounts", @@ -377,7 +377,7 @@ }, { "ExtensionData": {}, - "Description": "Can manage AD to Azure AD cloud provisioning, Azure AD Connect, and federation settings.", + "Description": "Can manage AD to Microsoft Entra cloud provisioning, Microsoft Entra Connect, and federation settings.", "IsEnabled": true, "IsSystem": true, "Name": "Hybrid Identity Administrator", @@ -385,7 +385,7 @@ }, { "ExtensionData": {}, - "Description": "Manage access using Azure AD for identity governance scenarios.", + "Description": "Manage access using Microsoft Entra ID for identity governance scenarios.", "IsEnabled": true, "IsSystem": true, "Name": "Identity Governance Administrator", @@ -457,7 +457,7 @@ }, { "ExtensionData": {}, - "Description": "Create and manage all aspects of workflows and tasks associated with Lifecycle Workflows in Azure AD.", + "Description": "Create and manage all aspects of workflows and tasks associated with Lifecycle Workflows in Microsoft Entra ID.", "IsEnabled": true, "IsSystem": true, "Name": "Lifecycle Workflows Administrator", @@ -545,10 +545,10 @@ }, { "ExtensionData": {}, - "Description": "Can manage all aspects of the Power BI product.", + "Description": "Manage all aspects of the Fabric and Power BI products.", "IsEnabled": true, "IsSystem": true, - "Name": "Power BI Administrator", + "Name": "Fabric Administrator", "ObjectId": "a9ea8996-122f-4c74-9520-8edcd192826c" }, { @@ -585,7 +585,7 @@ }, { "ExtensionData": {}, - "Description": "Can manage role assignments in Azure AD, and all aspects of Privileged Identity Management.", + "Description": "Can manage role assignments in Microsoft Entra ID, and all aspects of Privileged Identity Management.", "IsEnabled": true, "IsSystem": true, "Name": "Privileged Role Administrator", @@ -633,7 +633,7 @@ }, { "ExtensionData": {}, - "Description": "Can read security information and reports in Azure AD and Office 365.", + "Description": "Can read security information and reports in Microsoft Entra ID and Office 365.", "IsEnabled": true, "IsSystem": true, "Name": "Security Reader", @@ -807,14 +807,6 @@ "Name": "Yammer Administrator", "ObjectId": "810a2642-a034-447f-a5e8-41beaa378541" }, - { - "ExtensionData": {}, - "Description": "Assign the Customer Delegated Admin Relationship Administrator role to users who need to accept, view, or terminate GDAP relationships with partners.", - "IsEnabled": true, - "IsSystem": true, - "Name": "Customer Delegated Admin Relationship Administrator", - "ObjectId": "fc8ad4e2-40e4-4724-8317-bcda7503ecbf" - }, { "ExtensionData": {}, "Description": "Assign the AI Administrator role to users who need to manage all aspects of Microsoft 365 Copilot, AI-related enterprise services, copilot agents, and view usage reports and service health dashboards.", diff --git a/src/data/JitAdminRoles.json b/src/data/JitAdminRoles.json new file mode 100644 index 000000000000..ef7b7bec9ee9 --- /dev/null +++ b/src/data/JitAdminRoles.json @@ -0,0 +1,522 @@ +[ + { + "Name": "AI Administrator", + "ObjectId": "d2562ede-74db-457e-a7b6-544e236ebb61", + "Description": "Assign the AI Administrator role to users who need to manage all aspects of Microsoft 365 Copilot, AI-related enterprise services, copilot agents, and view usage reports and service health dashboards." + }, + { + "Name": "Application Administrator", + "ObjectId": "9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3", + "Description": "Can create and manage all aspects of app registrations and enterprise apps." + }, + { + "Name": "Application Developer", + "ObjectId": "cf1c38e5-3621-4004-a7cb-879624dced7c", + "Description": "Can create application registrations independent of the 'Users can register applications' setting." + }, + { + "Name": "Attack Payload Author", + "ObjectId": "9c6df0f2-1e7c-4dc3-b195-66dfbd24aa8f", + "Description": "Can create attack payloads that an administrator can initiate later." + }, + { + "Name": "Attack Simulation Administrator", + "ObjectId": "c430b396-e693-46cc-96f3-db01bf8bb62a", + "Description": "Can create and manage all aspects of attack simulation campaigns." + }, + { + "Name": "Attribute Assignment Administrator", + "ObjectId": "58a13ea3-c632-46ae-9ee0-9c0d43cd7f3d", + "Description": "Assign custom security attribute keys and values to supported Microsoft Entra objects." + }, + { + "Name": "Attribute Assignment Reader", + "ObjectId": "ffd52fa5-98dc-465c-991d-fc073eb59f8f", + "Description": "Read custom security attribute keys and values for supported Microsoft Entra objects." + }, + { + "Name": "Attribute Definition Administrator", + "ObjectId": "8424c6f0-a189-499e-bbd0-26c1753c96d4", + "Description": "Define and manage the definition of custom security attributes." + }, + { + "Name": "Attribute Definition Reader", + "ObjectId": "1d336d2c-4ae8-42ef-9711-b3604ce3fc2c", + "Description": "Read the definition of custom security attributes." + }, + { + "Name": "Attribute Log Administrator", + "ObjectId": "5b784334-f94b-471a-a387-e7219fc49ca2", + "Description": "Read audit logs and configure diagnostic settings for events related to custom security attributes." + }, + { + "Name": "Attribute Log Reader", + "ObjectId": "9c99539d-8186-4804-835f-fd51ef9e2dcd", + "Description": "Read audit logs related to custom security attributes." + }, + { + "Name": "Authentication Administrator", + "ObjectId": "c4e39bd9-1100-46d3-8c65-fb160da0071f", + "Description": "Allowed to view, set and reset authentication method information for any non-admin user." + }, + { + "Name": "Authentication Extensibility Administrator", + "ObjectId": "25a516ed-2fa0-40ea-a2d0-12923a21473a", + "Description": "Customize sign in and sign up experiences for users by creating and managing custom authentication extensions." + }, + { + "Name": "Authentication Policy Administrator", + "ObjectId": "0526716b-113d-4c15-b2c8-68e3c22b9f80", + "Description": "Can create and manage the authentication methods policy, tenant-wide MFA settings, password protection policy, and verifiable credentials." + }, + { + "Name": "Azure DevOps Administrator", + "ObjectId": "e3973bdf-4987-49ae-837a-ba8e231c7286", + "Description": "Can manage Azure DevOps organization policy and settings." + }, + { + "Name": "Azure Information Protection Administrator", + "ObjectId": "7495fdc4-34c4-4d15-a289-98788ce399fd", + "Description": "Can manage all aspects of the Azure Information Protection product." + }, + { + "Name": "B2C IEF Keyset Administrator", + "ObjectId": "aaf43236-0c0d-4d5f-883a-6955382ac081", + "Description": "Can manage secrets for federation and encryption in the Identity Experience Framework (IEF)." + }, + { + "Name": "B2C IEF Policy Administrator", + "ObjectId": "3edaf663-341e-4475-9f94-5c398ef6c070", + "Description": "Can create and manage trust framework policies in the Identity Experience Framework (IEF)." + }, + { + "Name": "Billing Administrator", + "ObjectId": "b0f54661-2d74-4c50-afa3-1ec803f12efe", + "Description": "Can perform common billing related tasks like updating payment information." + }, + { + "Name": "Cloud App Security Administrator", + "ObjectId": "892c5842-a9a6-463a-8041-72aa08ca3cf6", + "Description": "Can manage all aspects of the Cloud App Security product." + }, + { + "Name": "Cloud Application Administrator", + "ObjectId": "158c047a-c907-4556-b7ef-446551a6b5f7", + "Description": "Can create and manage all aspects of app registrations and enterprise apps except App Proxy." + }, + { + "Name": "Cloud Device Administrator", + "ObjectId": "7698a772-787b-4ac8-901f-60d6b08affd2", + "Description": "Full access to manage devices in Microsoft Entra ID." + }, + { + "Name": "Compliance Administrator", + "ObjectId": "17315797-102d-40b4-93e0-432062caca18", + "Description": "Can read and manage compliance configuration and reports in Microsoft Entra ID and Microsoft 365." + }, + { + "Name": "Compliance Data Administrator", + "ObjectId": "e6d1a23a-da11-4be4-9570-befc86d067a7", + "Description": "Creates and manages compliance content." + }, + { + "Name": "Conditional Access Administrator", + "ObjectId": "b1be1c3e-b65d-4f19-8427-f6fa0d97feb9", + "Description": "Can manage Conditional Access capabilities." + }, + { + "Name": "Customer Delegated Admin Relationship Administrator", + "ObjectId": "fc8ad4e2-40e4-4724-8317-bcda7503ecbf", + "Description": "Assign the Customer Delegated Admin Relationship Administrator role to users who need to accept, view, or terminate GDAP relationships with partners." + }, + { + "Name": "Customer Lockbox Access Approver", + "ObjectId": "5c4f9dcd-47dc-4cf7-8c9a-9e4207cbfc91", + "Description": "Can approve Microsoft support requests to access customer organizational data." + }, + { + "Name": "Desktop Analytics Administrator", + "ObjectId": "38a96431-2bdf-4b4c-8b6e-5d3d8abac1a4", + "Description": "Can access and manage Desktop management tools and services." + }, + { + "Name": "Directory Readers", + "ObjectId": "88d8e3e3-8f55-4a1e-953a-9b9898b8876b", + "Description": "Can read basic directory information. Commonly used to grant directory read access to applications and guests." + }, + { + "Name": "Directory Writers", + "ObjectId": "9360feb5-f418-4baa-8175-e2a00bac4301", + "Description": "Can read and write basic directory information. For granting access to applications, not intended for users." + }, + { + "Name": "Domain Name Administrator", + "ObjectId": "8329153b-31d0-4727-b945-745eb3bc5f31", + "Description": "Can manage domain names in cloud and on-premises." + }, + { + "Name": "Dynamics 365 Administrator", + "ObjectId": "44367163-eba1-44c3-98af-f5787879f96a", + "Description": "Can manage all aspects of the Dynamics 365 product." + }, + { + "Name": "Dynamics 365 Business Central Administrator", + "ObjectId": "963797fb-eb3b-4cde-8ce3-5878b3f32a3f", + "Description": "Access and perform all administrative tasks on Dynamics 365 Business Central environments." + }, + { + "Name": "Edge Administrator", + "ObjectId": "3f1acade-1e04-4fbc-9b69-f0302cd84aef", + "Description": "Manage all aspects of Microsoft Edge." + }, + { + "Name": "Entra Backup Administrator", + "ObjectId": "b6a27b2b-f905-4b2e-81b5-0d90e0ef1fdb", + "Description": "Manage all aspects of Microsoft Entra Backup, such as create recovery jobs and manage backup snapshots." + }, + { + "Name": "Entra Backup Reader", + "ObjectId": "f42252d9-5400-4d7b-b9ef-cc582dbb8577", + "Description": "Read all aspects of Microsoft Entra Backup, such as list all preview jobs, recovery jobs, backup snapshots, and create preview jobs." + }, + { + "Name": "Exchange Administrator", + "ObjectId": "29232cdf-9323-42fd-ade2-1d097af3e4de", + "Description": "Can manage all aspects of the Exchange product." + }, + { + "Name": "Exchange Backup Administrator", + "ObjectId": "49eb8f75-97e9-4e37-9b2b-6c3ebfcffa31", + "Description": "Back up and restore content (including granular restore) for Exchange in Microsoft 365 Backup." + }, + { + "Name": "Exchange Recipient Administrator", + "ObjectId": "31392ffb-586c-42d1-9346-e59415a2cc4e", + "Description": "Can create or update Exchange Online recipients within the Exchange Online organization." + }, + { + "Name": "External ID User Flow Administrator", + "ObjectId": "6e591065-9bad-43ed-90f3-e9424366d2f0", + "Description": "Can create and manage all aspects of user flows." + }, + { + "Name": "External ID User Flow Attribute Administrator", + "ObjectId": "0f971eea-41eb-4569-a71e-57bb8a3eff1e", + "Description": "Can create and manage the attribute schema available to all user flows." + }, + { + "Name": "External Identity Provider Administrator", + "ObjectId": "be2f45a1-457d-42af-a067-6ec1fa63bc45", + "Description": "Can configure identity providers for use in direct federation." + }, + { + "Name": "Fabric Administrator", + "ObjectId": "a9ea8996-122f-4c74-9520-8edcd192826c", + "Description": "Manage all aspects of the Fabric and Power BI products." + }, + { + "Name": "Global Administrator", + "ObjectId": "62e90394-69f5-4237-9190-012177145e10", + "Description": "Can manage all aspects of Microsoft Entra ID and Microsoft services that use Microsoft Entra identities." + }, + { + "Name": "Global Reader", + "ObjectId": "f2ef992c-3afb-46b9-b7cf-a126ee74c451", + "Description": "Can read everything that a Global Administrator can, but not update anything." + }, + { + "Name": "Global Secure Access Administrator", + "ObjectId": "ac434307-12b9-4fa1-a708-88bf58caabc1", + "Description": "Create and manage all aspects of Microsoft Entra Internet Access and Microsoft Entra Private Access, including managing access to public and private endpoints." + }, + { + "Name": "Groups Administrator", + "ObjectId": "fdd7a751-b60b-444a-984c-02652fe8fa1c", + "Description": "Members of this role can create/manage groups, create/manage groups settings like naming and expiration policies, and view groups activity and audit reports." + }, + { + "Name": "Guest Inviter", + "ObjectId": "95e79109-95c0-4d8e-aee3-d01accf2d47b", + "Description": "Can invite guest users independent of the 'members can invite guests' setting." + }, + { + "Name": "Helpdesk Administrator", + "ObjectId": "729827e3-9c14-49f7-bb1b-9608f156bbb8", + "Description": "Can reset passwords for non-administrators and Helpdesk Administrators." + }, + { + "Name": "Hybrid Identity Administrator", + "ObjectId": "8ac3fc64-6eca-42ea-9e69-59f4c7b60eb2", + "Description": "Can manage AD to Microsoft Entra cloud provisioning, Microsoft Entra Connect, and federation settings." + }, + { + "Name": "Identity Governance Administrator", + "ObjectId": "45d8d3c5-c802-45c6-b32a-1d70b5e1e86e", + "Description": "Manage access using Microsoft Entra ID for identity governance scenarios." + }, + { + "Name": "Insights Administrator", + "ObjectId": "eb1f4a8d-243a-41f0-9fbd-c7cdf6c5ef7c", + "Description": "Has administrative access in the Microsoft 365 Insights app." + }, + { + "Name": "Insights Analyst", + "ObjectId": "25df335f-86eb-4119-b717-0ff02de207e9", + "Description": "Access the analytical capabilities in Microsoft Viva Insights and run custom queries." + }, + { + "Name": "Insights Business Leader", + "ObjectId": "31e939ad-9672-4796-9c2e-873181342d2d", + "Description": "Can view and share dashboards and insights via the M365 Insights app." + }, + { + "Name": "Intune Administrator", + "ObjectId": "3a2c62db-5318-420d-8d74-23affee5d9d5", + "Description": "Can manage all aspects of the Intune product." + }, + { + "Name": "Kaizala Administrator", + "ObjectId": "74ef975b-6605-40af-a5d2-b9539d836353", + "Description": "Can manage settings for Microsoft Kaizala." + }, + { + "Name": "Knowledge Administrator", + "ObjectId": "b5a8dcf3-09d5-43a9-a639-8e29ef291470", + "Description": "Can configure knowledge, learning, and other intelligent features." + }, + { + "Name": "Knowledge Manager", + "ObjectId": "744ec460-397e-42ad-a462-8b3f9747a02c", + "Description": "Has access to topic management dashboard and can manage content." + }, + { + "Name": "License Administrator", + "ObjectId": "4d6ac14f-3453-41d0-bef9-a3e0c569773a", + "Description": "Can manage product licenses on users and groups." + }, + { + "Name": "Lifecycle Workflows Administrator", + "ObjectId": "59d46f88-662b-457b-bceb-5c3809e5908f", + "Description": "Create and manage all aspects of workflows and tasks associated with Lifecycle Workflows in Microsoft Entra ID." + }, + { + "Name": "Message Center Privacy Reader", + "ObjectId": "ac16e43d-7b2d-40e0-ac05-243ff356ab5b", + "Description": "Can read security messages and updates in Office 365 Message Center only." + }, + { + "Name": "Message Center Reader", + "ObjectId": "790c1fb9-7f7d-4f88-86a1-ef1f95c05c1b", + "Description": "Can read messages and updates for their organization in Office 365 Message Center only." + }, + { + "Name": "Microsoft 365 Backup Administrator", + "ObjectId": "1707125e-0aa2-4d4d-8655-a7c786c76a25", + "Description": "Back up and restore content across supported services (SharePoint, OneDrive, and Exchange Online) in Microsoft 365 Backup." + }, + { + "Name": "Microsoft 365 Migration Administrator", + "ObjectId": "8c8b803f-96e1-4129-9349-20738d9f9652", + "Description": "Perform all migration functionality to migrate content to Microsoft 365 using Migration Manager." + }, + { + "Name": "Microsoft Entra Joined Device Local Administrator", + "ObjectId": "9f06204d-73c1-4d4c-880a-6edb90606fd8", + "Description": "Users assigned to this role are added to the local administrators group on Microsoft Entra joined devices." + }, + { + "Name": "Microsoft Hardware Warranty Administrator", + "ObjectId": "1501b917-7653-4ff9-a4b5-203eaf33784f", + "Description": "Create and manage all aspects warranty claims and entitlements for Microsoft manufactured hardware, like Surface and HoloLens." + }, + { + "Name": "Microsoft Hardware Warranty Specialist", + "ObjectId": "281fe777-fb20-4fbb-b7a3-ccebce5b0d96", + "Description": "Create and read warranty claims for Microsoft manufactured hardware, like Surface and HoloLens." + }, + { + "Name": "Network Administrator", + "ObjectId": "d37c8bed-0711-4417-ba38-b4abe66ce4c2", + "Description": "Can manage network locations and review enterprise network design insights for Microsoft 365 Software as a Service applications." + }, + { + "Name": "Office Apps Administrator", + "ObjectId": "2b745bdf-0803-4d80-aa65-822c4493daac", + "Description": "Can manage Office apps cloud services, including policy and settings management, and manage the ability to select, unselect and publish 'what's new' feature content to end-user's devices." + }, + { + "Name": "Organizational Messages Writer", + "ObjectId": "507f53e4-4e52-4077-abd3-d2e1558b6ea2", + "Description": "Write, publish, manage, and review the organizational messages for end-users through Microsoft product surfaces." + }, + { + "Name": "Password Administrator", + "ObjectId": "966707d0-3269-4727-9be2-8c3a10f19b9d", + "Description": "Can reset passwords for non-administrators and Password Administrators." + }, + { + "Name": "Permissions Management Administrator", + "ObjectId": "af78dc32-cf4d-46f9-ba4e-4428526346b5", + "Description": "Manage all aspects of Entra Permissions Management." + }, + { + "Name": "Power Platform Administrator", + "ObjectId": "11648597-926c-4cf3-9c36-bcebb0ba8dcc", + "Description": "Can create and manage all aspects of Microsoft Dynamics 365, PowerApps and Microsoft Flow." + }, + { + "Name": "Printer Administrator", + "ObjectId": "644ef478-e28f-4e28-b9dc-3fdde9aa0b1f", + "Description": "Can manage all aspects of printers and printer connectors." + }, + { + "Name": "Printer Technician", + "ObjectId": "e8cef6f1-e4bd-4ea8-bc07-4b8d950f4477", + "Description": "Can manage all aspects of printers and printer connectors." + }, + { + "Name": "Privileged Authentication Administrator", + "ObjectId": "7be44c8a-adaf-4e2a-84d6-ab2649e08a13", + "Description": "Allowed to view, set and reset authentication method information for any user (admin or non-admin)." + }, + { + "Name": "Privileged Role Administrator", + "ObjectId": "e8611ab8-c189-46e8-94e1-60213ab1f814", + "Description": "Can manage role assignments in Microsoft Entra ID, and all aspects of Privileged Identity Management." + }, + { + "Name": "Reports Reader", + "ObjectId": "4a5d8f65-41da-4de4-8968-e035b65339cf", + "Description": "Can read sign-in and audit reports." + }, + { + "Name": "Search Administrator", + "ObjectId": "0964bb5e-9bdb-4d7b-ac29-58e794862a40", + "Description": "Can create and manage all aspects of Microsoft Search settings." + }, + { + "Name": "Search Editor", + "ObjectId": "8835291a-918c-4fd7-a9ce-faa49f0cf7d9", + "Description": "Can create and manage the editorial content such as bookmarks, Q and As, locations, floorplan." + }, + { + "Name": "Security Administrator", + "ObjectId": "194ae4cb-b126-40b2-bd5b-6091b380977d", + "Description": "Security Administrator allows ability to read and manage security configuration and reports." + }, + { + "Name": "Security Operator", + "ObjectId": "5f2222b1-57c3-48ba-8ad5-d4759f1fde6f", + "Description": "Creates and manages security events." + }, + { + "Name": "Security Reader", + "ObjectId": "5d6b6bb7-de71-4623-b4af-96380a352509", + "Description": "Can read security information and reports in Microsoft Entra ID and Office 365." + }, + { + "Name": "Service Support Administrator", + "ObjectId": "f023fd81-a637-4b56-95fd-791ac0226033", + "Description": "Can read service health information and manage support tickets." + }, + { + "Name": "SharePoint Administrator", + "ObjectId": "f28a1f50-f6e7-4571-818b-6a12f2af6b6c", + "Description": "Can manage all aspects of the SharePoint service." + }, + { + "Name": "SharePoint Backup Administrator", + "ObjectId": "9d3e04ba-3ee4-4d1b-a3a7-9aef423a09be", + "Description": "Back up and restore content (including granular restore) for SharePoint and OneDrive in Microsoft 365 Backup." + }, + { + "Name": "SharePoint Embedded Administrator", + "ObjectId": "1a7d78b6-429f-476b-8eb-35fb715fffd4", + "Description": "Manage all aspects of SharePoint Embedded containers." + }, + { + "Name": "Skype for Business Administrator", + "ObjectId": "75941009-915a-4869-abe7-691bff18279e", + "Description": "Can manage all aspects of the Skype for Business product." + }, + { + "Name": "Teams Administrator", + "ObjectId": "69091246-20e8-4a56-aa4d-066075b2a7a8", + "Description": "Can manage the Microsoft Teams service." + }, + { + "Name": "Teams Communications Administrator", + "ObjectId": "baf37b3a-610e-45da-9e62-d9d1e5e8914b", + "Description": "Can manage calling and meetings features within the Microsoft Teams service." + }, + { + "Name": "Teams Communications Support Engineer", + "ObjectId": "f70938a0-fc10-4177-9e90-2178f8765737", + "Description": "Can troubleshoot communications issues within Teams using advanced tools." + }, + { + "Name": "Teams Communications Support Specialist", + "ObjectId": "fcf91098-03e3-41a9-b5ba-6f0ec8188a12", + "Description": "Can troubleshoot communications issues within Teams using basic tools." + }, + { + "Name": "Teams Devices Administrator", + "ObjectId": "3d762c5a-1b6c-493f-843e-55a3b42923d4", + "Description": "Can perform management related tasks on Teams certified devices." + }, + { + "Name": "Teams Telephony Administrator", + "ObjectId": "aa38014f-0993-46e9-9b45-30501a20909d", + "Description": "Manage voice and telephony features and troubleshoot communication issues within the Microsoft Teams service." + }, + { + "Name": "Tenant Creator", + "ObjectId": "112ca1a2-15ad-4102-995e-45b0bc479a6a", + "Description": "Create new Microsoft Entra or Azure AD B2C tenants." + }, + { + "Name": "Usage Summary Reports Reader", + "ObjectId": "75934031-6c7e-415a-99d7-48dbd49e875e", + "Description": "Can see only tenant level aggregates in Microsoft 365 Usage Analytics and Productivity Score." + }, + { + "Name": "User Administrator", + "ObjectId": "fe930be7-5e62-47db-91af-98c3a49a38b1", + "Description": "Can manage all aspects of users and groups, including resetting passwords for limited admins." + }, + { + "Name": "User Experience Success Manager", + "ObjectId": "27460883-1df1-4691-b032-3b79643e5e63", + "Description": "View product feedback, survey results, and reports to find training and communication opportunities." + }, + { + "Name": "Virtual Visits Administrator", + "ObjectId": "e300d9e7-4a2b-4295-9eff-f1c78b36cc98", + "Description": "Manage and share Virtual Visits information and metrics from admin centers or the Virtual Visits app." + }, + { + "Name": "Viva Goals Administrator", + "ObjectId": "92b086b3-e367-4ef2-b869-1de128fb986e", + "Description": "Manage and configure all aspects of Microsoft Viva Goals." + }, + { + "Name": "Viva Pulse Administrator", + "ObjectId": "87761b17-1ed2-4af3-9acd-92a150038160", + "Description": "Can manage all settings for Microsoft Viva Pulse app." + }, + { + "Name": "Windows 365 Administrator", + "ObjectId": "11451d60-acb2-45eb-a7d6-43d0f0125c13", + "Description": "Can provision and manage all aspects of Cloud PCs." + }, + { + "Name": "Windows Update Deployment Administrator", + "ObjectId": "32696413-001a-46ae-978c-ce0f6b3620d2", + "Description": "Can create and manage all aspects of Windows Update deployments through the Windows Update for Business deployment service." + }, + { + "Name": "Yammer Administrator", + "ObjectId": "810a2642-a034-447f-a5e8-41beaa378541", + "Description": "Manage all aspects of Yammer." + } +] diff --git a/src/data/alerts.json b/src/data/alerts.json index 9c907c4f5e92..57a57580a27d 100644 --- a/src/data/alerts.json +++ b/src/data/alerts.json @@ -68,6 +68,11 @@ "inputType": "switch", "inputLabel": "Exclude disabled users?", "inputName": "ExcludeDisabled" + }, + { + "inputType": "switch", + "inputLabel": "Include users who have never signed in (e.g. sign-in blocked)?", + "inputName": "IncludeNeverSignedIn" } ] }, @@ -441,9 +446,9 @@ }, { "name": "HuntressRogueApps", - "label": "Alert on Huntress Rogue Apps detected", + "label": "Alert on Huntress or CIPP Rogue Apps detected", "recommendedRunInterval": "4h", - "description": "Huntress has provided a repository of known rogue apps that are commonly used in BEC, data exfiltration and other Microsoft 365 attacks. This alert will notify you if any of these apps are detected in the selected tenant(s). For more information, see https://huntresslabs.github.io/rogueapps/.", + "description": "Huntress has provided a repository of known rogue apps that are commonly used in BEC, data exfiltration and other Microsoft 365 attacks. This alert will notify you if any of these apps are detected in the selected tenant(s). For more information, see https://huntresslabs.github.io/rogueapps/. CIPP also has a list of community collected rogue apps.", "requiresInput": true, "inputType": "switch", "inputLabel": "Ignore Disabled Apps?", diff --git a/src/data/countryList.json b/src/data/countryList.json index 9595ba9f517c..49ef2e53ea7d 100644 --- a/src/data/countryList.json +++ b/src/data/countryList.json @@ -12,7 +12,6 @@ { "Code": "AR", "Name": "Argentina" }, { "Code": "AM", "Name": "Armenia" }, { "Code": "AW", "Name": "Aruba" }, - { "Code": "AC", "Name": "Ascension Island" }, { "Code": "AU", "Name": "Australia" }, { "Code": "AT", "Name": "Austria" }, { "Code": "AZ", "Name": "Azerbaijan" }, @@ -61,7 +60,6 @@ { "Code": "CY", "Name": "Cyprus" }, { "Code": "CZ", "Name": "Czech Republic" }, { "Code": "DK", "Name": "Denmark" }, - { "Code": "DG", "Name": "Diego Garcia" }, { "Code": "DJ", "Name": "Djibouti" }, { "Code": "DM", "Name": "Dominica" }, { "Code": "DO", "Name": "Dominican Republic" }, diff --git a/src/data/portals.json b/src/data/portals.json index 874810c6d976..30709d4b30d7 100644 --- a/src/data/portals.json +++ b/src/data/portals.json @@ -72,7 +72,7 @@ "icon": "Shield" }, { - "label": "Compliance", + "label": "Purview", "name": "Compliance_Portal", "url": "https://purview.microsoft.com/?tid=customerId", "variable": "customerId", diff --git a/src/data/standards.json b/src/data/standards.json index 6552e8000429..13b65e4926fd 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -1587,8 +1587,8 @@ "ZTNA21807", "ZTNA21810" ], - "helpText": "Disables users from being able to consent to applications, except for those specified in the field below", - "docsDescription": "Requires users to get administrator consent before sharing data with applications. You can preapprove specific applications.", + "helpText": "Disables users from being able to consent to applications, except for those specified in the field below. This standard conflicts with the \"Allow users to consent to applications with low security risk\" standard; only one of the two should be assigned per tenant.", + "docsDescription": "Requires users to get administrator consent before sharing data with applications. You can preapprove specific applications. This standard conflicts with the \"Allow users to consent to applications with low security risk\" (OauthConsentLowSec) standard. Enabling both on the same tenant causes a remediation conflict, so only assign one.", "executiveText": "Requires administrative approval before employees can grant applications access to company data, preventing unauthorized data sharing and potential security breaches. This protects against malicious applications while allowing approved business tools to function normally.", "addedComponent": [ { @@ -1609,8 +1609,8 @@ "name": "standards.OauthConsentLowSec", "cat": "Entra (AAD) Standards", "tag": ["IntegratedApps"], - "helpText": "Sets the default oauth consent level so users can consent to applications that have low risks.", - "docsDescription": "Allows users to consent to applications with low assigned risk.", + "helpText": "Sets the default oauth consent level so users can consent to applications that have low risks. This standard conflicts with the \"Require admin consent for applications\" standard; only one of the two should be assigned per tenant.", + "docsDescription": "Allows users to consent to applications with low assigned risk. This standard conflicts with the \"Require admin consent for applications (Prevent OAuth phishing)\" (OauthConsent) standard. Enabling both on the same tenant causes a remediation conflict, so only assign one.", "executiveText": "Allows employees to approve low-risk applications without administrative intervention, balancing security with productivity. This provides a middle ground between complete restriction and open access, enabling business agility while maintaining protection against high-risk applications.", "label": "Allow users to consent to applications with low security risk (Prevent OAuth phishing. Lower impact, less secure)", "impact": "Medium Impact", @@ -1656,27 +1656,38 @@ "name": "standards.StaleEntraDevices", "cat": "Entra (AAD) Standards", "tag": ["Essential 8 (1501)", "NIST CSF 2.0 (ID.AM-08)", "NIST CSF 2.0 (PR.PS-03)"], - "helpText": "**Remediate is currently not available**. Cleans up Entra devices that have not connected/signed in for the specified number of days.", - "docsDescription": "Remediate is currently not available. Cleans up Entra devices that have not connected/signed in for the specified number of days. First disables and later deletes the devices. More info can be found in the [Microsoft documentation](https://learn.microsoft.com/en-us/entra/identity/devices/manage-stale-devices)", + "helpText": "Cleans up Entra devices that have not connected/signed in for the specified number of days. Remediation first disables stale enabled devices and, on a later run, deletes stale devices that are already disabled. Hybrid-joined, Intune-managed and Autopilot devices are skipped. Deleting a device permanently removes any BitLocker recovery keys stored on it.", + "docsDescription": "Cleans up Entra devices that have not connected/signed in for the specified number of days. Remediation first disables stale enabled devices once they pass the disable threshold, and later deletes devices that are already disabled once they have been inactive for the disable threshold plus the configured grace delta (deletion age = disable threshold + grace days). The disable-before-delete grace period is further guaranteed by never deleting a device in the same pass it was disabled. Hybrid-joined (on-premises synced), Intune-managed/compliant, and system-managed Autopilot devices are excluded, in line with the [Microsoft guidance](https://learn.microsoft.com/en-us/entra/identity/devices/manage-stale-devices). **Warning:** deleting a device permanently removes any BitLocker recovery keys stored on that device object.", "executiveText": "Automatically identifies and removes inactive devices that haven't connected to company systems for a specified period, reducing security risks from abandoned or lost devices. This maintains a clean device inventory and prevents potential unauthorized access through dormant device registrations.", "addedComponent": [ { "type": "number", "name": "standards.StaleEntraDevices.deviceAgeThreshold", - "label": "Days before stale(Do not set below 30)", + "required": true, + "defaultValue": 90, + "label": "Days before stale (disables the device after this many days of inactivity, minimum 30)", "validators": { "min": { "value": 30, "message": "Minimum value is 30" } } + }, + { + "type": "number", + "name": "standards.StaleEntraDevices.deviceDeleteThreshold", + "defaultValue": 0, + "label": "Grace days after disable before deletion (0 = never delete). Devices are deleted once inactive for the disable threshold plus this many additional days.", + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" } + } } ], - "disabledFeatures": { "report": false, "warn": false, "remediate": true }, + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, "label": "Cleanup stale Entra devices", "impact": "High Impact", "impactColour": "danger", "addedDate": "2025-01-19", "powershellEquivalent": "Remove-MgDevice, Update-MgDevice or Graph API", "recommendedBy": [], - "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] + "requiredCapabilities": [] }, { "name": "standards.UndoOauth", @@ -5059,6 +5070,7 @@ }, { "name": "standards.SPDirectSharing", + "deprecated": true, "cat": "SharePoint Standards", "tag": [], "helpText": "This standard has been deprecated in favor of the Default Sharing Link standard. ", diff --git a/src/layouts/TabbedLayout.jsx b/src/layouts/TabbedLayout.jsx index 33c861fb85dd..af7403327d53 100644 --- a/src/layouts/TabbedLayout.jsx +++ b/src/layouts/TabbedLayout.jsx @@ -4,12 +4,15 @@ import { Box, Divider, Stack, Tab, Tabs } from '@mui/material' import { useSearchParams } from 'next/navigation' import { ApiGetCall } from '../api/ApiCall' import { getIconByName } from '../utils/icon-registry' +import { useSettings } from '../hooks/use-settings' export const TabbedLayout = (props) => { const { tabOptions, children } = props const router = useRouter() const pathname = usePathname() const searchParams = useSearchParams() + const settings = useSettings() + const showAdvanced = settings?.showAdvancedTools === true const featureFlags = ApiGetCall({ url: '/api/ListFeatureFlags', @@ -18,17 +21,21 @@ export const TabbedLayout = (props) => { }) const visibleTabs = useMemo(() => { - if (!featureFlags.isSuccess || !Array.isArray(featureFlags.data)) return tabOptions + // Per-user gate: tabs marked { advanced: true } are hidden unless the user enabled Advanced + // Views in preferences (Developer Options). Keeps diagnostic pages out of day-to-day use. + let tabs = showAdvanced ? tabOptions : tabOptions.filter((option) => !option.advanced) + + if (!featureFlags.isSuccess || !Array.isArray(featureFlags.data)) return tabs const disabledPages = featureFlags.data .filter((flag) => flag.Enabled === false || flag.enabled === false) .flatMap((flag) => flag.Pages || flag.pages || []) .filter((page) => typeof page === 'string') - if (disabledPages.length === 0) return tabOptions + if (disabledPages.length === 0) return tabs - return tabOptions.filter((option) => !disabledPages.includes(option.path)) - }, [tabOptions, featureFlags.isSuccess, featureFlags.data]) + return tabs.filter((option) => !disabledPages.includes(option.path)) + }, [tabOptions, featureFlags.isSuccess, featureFlags.data, showAdvanced]) const handleTabsChange = (event, value) => { // Preserve existing query parameters when changing tabs diff --git a/src/layouts/config.js b/src/layouts/config.js index 2556368447c7..9958af388d55 100644 --- a/src/layouts/config.js +++ b/src/layouts/config.js @@ -124,7 +124,7 @@ export const nativeMenuItems = [ permissions: ['Identity.User.*'], }, { - title: 'AAD Connect Report', + title: 'Microsoft Entra Connect Report', path: '/identity/reports/azure-ad-connect-report', permissions: ['Identity.User.*'], }, @@ -285,6 +285,11 @@ export const nativeMenuItems = [ path: '/tenant/reports/graph-office-reports', permissions: ['Tenant.Reports.*'], }, + { + title: 'Custom Test Report', + path: '/tenant/reports/custom-test-report', + permissions: ['Tenant.Reports.*'], + }, ], }, { @@ -358,6 +363,11 @@ export const nativeMenuItems = [ path: '/security/defender/list-defender-tvm', permissions: ['Security.Alert.*'], }, + { + title: 'CVE Management', + path: '/security/defender/defender-cve-exceptions', + permissions: ['Security.Alert.*'], + }, ], }, { @@ -374,6 +384,11 @@ export const nativeMenuItems = [ path: '/security/reports/mde-onboarding', permissions: ['Security.Defender.*'], }, + { + title: 'Vulnerability Report', + path: '/security/reports/cve-report', + permissions: ['Security.Defender.*'], + }, ], }, { @@ -538,6 +553,7 @@ export const nativeMenuItems = [ title: 'Application Templates', path: '/endpoint/applications/templates', permissions: ['Endpoint.Application.*'], + scope: 'global', }, ], }, @@ -680,6 +696,21 @@ export const nativeMenuItems = [ path: '/teams-share/sharepoint', permissions: ['Sharepoint.Admin.*'], }, + { + title: 'Deleted Sites', + path: '/teams-share/deleted-sites', + permissions: ['Sharepoint.Admin.*'], + }, + { + title: 'Sharing Report', + path: '/teams-share/sharing-report', + permissions: ['Sharepoint.Site.*'], + }, + { + title: 'External Users', + path: '/teams-share/external-users', + permissions: ['Sharepoint.Site.*'], + }, { title: 'Teams', permissions: ['Teams.Group.*'], diff --git a/src/layouts/top-nav.js b/src/layouts/top-nav.js index fb5c7483e20f..e3acd76954bc 100644 --- a/src/layouts/top-nav.js +++ b/src/layouts/top-nav.js @@ -25,6 +25,7 @@ import { IconButton, Stack, SvgIcon, + Tooltip, useMediaQuery, Popover, List, @@ -300,13 +301,15 @@ export const TopNav = (props) => { {!mdDown && ( - openUniversalSearch('Users')} - title="Open Universal Search (Ctrl/Cmd+Shift+F)" - > - - + + openUniversalSearch('Users')} + aria-label="Open universal search (Ctrl/Cmd+Shift+F)" + > + + + )} {!mdDown && ( @@ -316,15 +319,17 @@ export const TopNav = (props) => { )} {!mdDown && ( - openUniversalSearch('Pages')} - title="Open Page Search (Ctrl/Cmd+K)" - > - - - - + + openUniversalSearch('Pages')} + aria-label="Open page search (Ctrl/Cmd+K)" + > + + + + + )} {showPopoverBookmarks && ( <> @@ -623,7 +628,21 @@ export const TopNav = (props) => { }, }} > - Universal Search + + + Universal Search + + Pages: Ctrl/Cmd+K ยท Users: Ctrl/Cmd+Shift+F ยท Tenant: Ctrl/Cmd+Alt+K + + + { }, { name: "portalLinks.Compliance_Portal", - label: "Compliance", + label: "Purview", }, { name: "portalLinks.Power_Platform_Portal", @@ -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. + + + + + + + + + + + + + + { + switch (String(risk).toLowerCase()) { + case 'high': + return 'error' + case 'medium': + return 'warning' + case 'low': + return 'info' + case 'informational': + return 'success' + default: + return 'default' + } +} + +// Row detail shown when an AI tool row is clicked, for both the Intune and the Entra table. +const AiToolDetail = ({ row }) => { + if (!row) return null + const isIntune = Array.isArray(row.managedDevices) + + const properties = isIntune + ? [ + { label: 'Application', value: row.application }, + { label: 'Vendor', value: row.vendor }, + { label: 'Category', value: row.category }, + { label: 'Publisher', value: row.publisher }, + { label: 'Version', value: row.version }, + { label: 'Platform', value: row.platform }, + { label: 'Device Installs', value: row.deviceCount }, + ] + : [ + { label: 'Application', value: row.application }, + { label: 'Vendor', value: row.vendor }, + { label: 'Category', value: row.category }, + { label: 'Application ID', value: row.applicationId }, + { + label: 'First Consented', + value: row.firstConsentedDateTime + ? new Date(row.firstConsentedDateTime).toLocaleString() + : 'Unknown', + }, + { label: 'Sign-ins (7 Days)', value: row.signInsLast7Days }, + { label: 'Active Users (7 Days)', value: row.activeUsersLast7Days }, + ] + + return ( + + + {row.aiTool} + + + + {row.toolDescription && ( + + {row.toolDescription} + + )} + {row.riskReason && ( + { + const color = riskChipColor(row.catalogRisk ?? row.risk) + return color === 'default' ? 'divider' : `${color}.main` + })(), + }} + > + + Why {row.aiTool} is rated {row.catalogRisk ?? row.risk} risk + + + {row.riskReason} + + + )} + {row.status === 'Sanctioned' ? ( + + This tool is marked as company sanctioned for this tenant, so it reports the Informational + risk level. Use the Remove Company Sanctioned Status action to restore its catalog risk + level. + + ) : ( + + This tool is not company sanctioned. If your customer approves its use, mark it as company + sanctioned via the row actions to set its risk level to Informational. + + )} + + + {properties + .filter((prop) => prop.value !== undefined && prop.value !== null && prop.value !== '') + .map((prop) => ( + + + {prop.label} + + {String(prop.value)} + + ))} + + {!isIntune && + Array.isArray(row.approvedPermissions) && + row.approvedPermissions.length > 0 && ( + <> + + + Approved Permissions + + + {row.approvedPermissions.map((permission) => ( + + ))} + + + )} + + {isIntune ? ( + + ) : ( + + )} + + ) +} + // Drawer listing the users who signed in to an AI application in the last 7 days. const ApplicationUsersDrawer = ({ row, drawerVisible, setDrawerVisible }) => ( { const currentTenant = useSettings().currentTenant const syncDialog = useDialog() const queryKey = `ListShadowAI-${currentTenant}` + const [topToolsMode, setTopToolsMode] = useState('installations') const shadowAi = ApiGetCall({ url: '/api/ListShadowAI', @@ -63,6 +224,69 @@ const Page = () => { const needsSync = shadowAi.isSuccess && !summary.intuneSynced && !summary.entraSynced const showCharts = shadowAi.isFetching || byCategory.length > 0 + const topToolsMetric = topToolsMode === 'users' ? 'users' : 'devices' + const topToolsSorted = [...topTools].sort( + (a, b) => (b[topToolsMetric] ?? 0) - (a[topToolsMetric] ?? 0) + ) + + const sanctionActions = [ + { + label: 'Mark as Company Sanctioned', + type: 'POST', + url: '/api/ExecShadowAISanction', + icon: ( + + + + ), + data: { Tool: 'aiTool', Action: '!Sanction' }, + confirmText: + "Mark [aiTool] as company sanctioned for this tenant? Its risk level will report as Informational and its status as 'Sanctioned'.", + relatedQueryKeys: [queryKey], + condition: (row) => row.status !== 'Sanctioned', + multiPost: false, + }, + { + label: 'Remove Company Sanctioned Status', + type: 'POST', + url: '/api/ExecShadowAISanction', + icon: ( + + + + ), + data: { Tool: 'aiTool', Action: '!Unsanction' }, + confirmText: + "Remove the company sanctioned status from [aiTool]? Its catalog risk level will apply again and its status will report as 'Unsanctioned'.", + relatedQueryKeys: [queryKey], + condition: (row) => row.status === 'Sanctioned', + multiPost: false, + }, + ] + + const statusFilters = [ + { + filterName: 'Sanctioned', + value: [{ id: 'status', value: 'Sanctioned' }], + type: 'column', + }, + { + filterName: 'Unsanctioned', + value: [{ id: 'status', value: 'Unsanctioned' }], + type: 'column', + }, + { + filterName: 'High Risk', + value: [{ id: 'risk', value: 'High' }], + type: 'column', + }, + ] + + const toolDetailOffCanvas = { + size: 'lg', + children: (row) => , + } + return ( @@ -88,18 +312,25 @@ const Page = () => { ? `Last data refresh: ${new Date(summary.lastDataRefresh).toLocaleString()}` : ''} - + + + + {needsSync && ( @@ -154,14 +385,27 @@ const Page = () => { title="Top AI Tools" isFetching={shadowAi.isFetching} chartType="bar" - labels={topTools.map((item) => item.tool)} - chartSeries={topTools.map((item) => item.footprint)} - totalLabel="Devices + Users" + labels={topToolsSorted.map((item) => item.tool)} + chartSeries={topToolsSorted.map((item) => item[topToolsMetric] ?? 0)} + totalLabel={ + topToolsMode === 'users' ? 'Active Users (7 Days)' : 'Device Installs' + } + headerAction={ + value && setTopToolsMode(value)} + > + By installations + By users + + } /> item.risk)} @@ -177,15 +421,20 @@ const Page = () => { title="AI Software on Managed Devices (Intune)" isFetching={shadowAi.isFetching} data={data.detectedApps ?? []} + actions={sanctionActions} + filters={statusFilters} + offCanvas={toolDetailOffCanvas} + offCanvasOnRowClick={true} simpleColumns={[ 'application', 'aiTool', 'category', 'risk', + 'status', 'publisher', 'platform', 'version', - 'deviceCount', + 'managedDevices', ]} /> @@ -195,6 +444,7 @@ const Page = () => { title="AI Applications in Entra" isFetching={shadowAi.isFetching} actions={[ + ...sanctionActions, { label: 'Application Users', icon: , @@ -209,11 +459,15 @@ const Page = () => { }, ]} data={data.consentedApps ?? []} + filters={statusFilters} + offCanvas={toolDetailOffCanvas} + offCanvasOnRowClick={true} simpleColumns={[ 'application', 'aiTool', 'category', 'risk', + 'status', 'applicationId', 'approvedPermissions', 'signInsLast7Days', diff --git a/src/pages/dashboardv2/index.js b/src/pages/dashboardv2/index.js index 48e3bd3b3b62..b2934a05964f 100644 --- a/src/pages/dashboardv2/index.js +++ b/src/pages/dashboardv2/index.js @@ -28,6 +28,7 @@ import { LicenseCard } from '../../components/CippComponents/LicenseCard' import { TenantInfoCard } from '../../components/CippComponents/TenantInfoCard' import { TenantMetricsGrid } from '../../components/CippComponents/TenantMetricsGrid' import { AssessmentCard } from '../../components/CippComponents/AssessmentCard' +import { AlertsOverviewCard } from '../../components/CippComponents/AlertsOverviewCard' import { CippReportToolbar } from '../../components/CippComponents/CippReportToolbar' import { Assessment as AssessmentIcon } from '@mui/icons-material' import ChevronDownIcon from '@heroicons/react/24/outline/ChevronDownIcon' @@ -346,6 +347,11 @@ const Page = () => { + {/* Alerts Section - Full Width */} + + + + {/* Identity Section - 2 Column Grid */} diff --git a/src/pages/email/administration/contacts/index.js b/src/pages/email/administration/contacts/index.js index 471a06aa7e85..08ddce216c8b 100644 --- a/src/pages/email/administration/contacts/index.js +++ b/src/pages/email/administration/contacts/index.js @@ -30,6 +30,14 @@ const Page = () => { displayName: "DisplayName", type: "!Contact", }, + // Pre-select the current source of authority; leave unselected when the + // selected rows have mixed states + defaultvalues: (row) => { + const states = [ + ...new Set((Array.isArray(row) ? row : [row]).map((r) => r?.IsDirSynced === true)), + ]; + return states.length === 1 ? { isCloudManaged: String(!states[0]) } : {}; + }, fields: [ { type: "radio", @@ -39,12 +47,29 @@ const Page = () => { { label: "Cloud Managed", value: true }, { label: "On-Premises Managed", value: false }, ], - validators: { required: "Please select a source of authority" }, + validators: { + required: "Please select a source of authority", + validate: (value, formValues, row) => { + const states = [ + ...new Set( + (Array.isArray(row) ? row : [row]).map((r) => r?.IsDirSynced === true) + ), + ]; + if (states.length === 1 && String(value) === String(!states[0])) { + return "Source of authority is unchanged"; + } + return true; + }, + }, }, ], confirmText: "Are you sure you want to change the source of authority for '[DisplayName]'? Setting it to On-Premises Managed will take until the next sync cycle to show the change.", multiPost: false, + // The SOA API targets the Graph org contact (graphId), which only exists for + // contacts that are or were directory-synced; cloud-native mail contacts have + // no Graph counterpart and the request would be meaningless + condition: (row) => !!row?.graphId, }, { label: "Remove Contact", diff --git a/src/pages/email/reports/SharedMailboxEnabledAccount/index.js b/src/pages/email/reports/SharedMailboxEnabledAccount/index.js index 3dd3d08c5398..0c9794f33e45 100644 --- a/src/pages/email/reports/SharedMailboxEnabledAccount/index.js +++ b/src/pages/email/reports/SharedMailboxEnabledAccount/index.js @@ -1,49 +1,68 @@ import { Layout as DashboardLayout } from "../../../../layouts/index.js"; import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; import { Block } from "@mui/icons-material"; +import { useCippReportDB } from "../../../../components/CippComponents/CippReportDBControls"; const Page = () => { + const reportDB = useCippReportDB({ + apiUrl: "/api/ListSharedMailboxAccountEnabled", + queryKey: "ListSharedMailboxAccountEnabled", + cacheName: "Mailboxes", + syncTitle: "Sync Shared Mailbox Cache", + allowToggle: true, + defaultCached: false, + }); + + const simpleColumns = [ + ...reportDB.cacheColumns.filter((c) => c === "Tenant"), + "UserPrincipalName", + "displayName", + "accountEnabled", + "assignedLicenses", + "onPremisesSyncEnabled", + ...reportDB.cacheColumns.filter((c) => c !== "Tenant"), + ]; + return ( - , - url: "/api/ExecDisableUser", - data: { ID: "id" }, - confirmText: "Are you sure you want to block the sign-in for this mailbox?", - condition: (row) => row.accountEnabled && !row.onPremisesSyncEnabled, - }, - ]} - offCanvas={{ - extendedInfoFields: [ - "UserPrincipalName", - "displayName", - "accountEnabled", - "assignedLicenses", - "onPremisesSyncEnabled", - ], - }} - simpleColumns={[ - "UserPrincipalName", - "displayName", - "accountEnabled", - "assignedLicenses", - "onPremisesSyncEnabled", - ]} - filters={[ - { - id: "accountEnabled", - value: "Yes", - }, - ]} - /> + <> + , + url: "/api/ExecDisableUser", + data: { ID: "id" }, + confirmText: "Are you sure you want to block the sign-in for this mailbox?", + condition: (row) => row.accountEnabled && !row.onPremisesSyncEnabled, + }, + ]} + offCanvas={{ + extendedInfoFields: [ + "UserPrincipalName", + "displayName", + "accountEnabled", + "assignedLicenses", + "onPremisesSyncEnabled", + ], + }} + simpleColumns={simpleColumns} + filters={[ + { + id: "accountEnabled", + value: "Yes", + }, + ]} + /> + {reportDB.syncDialog} + ); }; -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page}; export default Page; diff --git a/src/pages/email/reports/mailbox-permissions/index.js b/src/pages/email/reports/mailbox-permissions/index.js index da771b6e90f0..cef96534a32a 100644 --- a/src/pages/email/reports/mailbox-permissions/index.js +++ b/src/pages/email/reports/mailbox-permissions/index.js @@ -3,7 +3,7 @@ import { CippTablePage } from '../../../../components/CippComponents/CippTablePa import { useState } from 'react' import { Tooltip, Chip } from '@mui/material' import { Stack } from '@mui/system' -import { Person, Inbox } from '@mui/icons-material' +import { Person, Inbox, Delete } from '@mui/icons-material' import { useCippReportDB } from '../../../../components/CippComponents/CippReportDBControls' const Page = () => { @@ -20,6 +20,131 @@ const Page = () => { cacheColumns: ['MailboxCacheTimestamp', 'PermissionCacheTimestamp'], }) + // Refetch both views after a removal โ€” the endpoint now syncs the cached report + const relatedQueryKeys = [ + `${reportDB.resolvedQueryKey}-true`, + `${reportDB.resolvedQueryKey}-false`, + ] + + const byMailboxActions = [ + { + label: 'Bulk Remove Mailbox Permissions', + type: 'POST', + url: '/api/ExecModifyMBPerms', + icon: , + confirmText: 'Remove the selected permissions from the selected mailboxes?', + multiPost: false, + relatedQueryKeys, + fields: [ + { + type: 'autoComplete', + name: 'permissionsToRemove', + label: 'Permissions to remove', + multiple: true, + creatable: false, + required: true, + options: (rows) => { + const rowArray = Array.isArray(rows) ? rows : [rows] + const seen = new Set() + const options = [] + rowArray.forEach((row) => + (row?.Permissions ?? []).forEach((p) => { + const key = `${p.User}|${p.AccessRights}` + if (!seen.has(key)) { + seen.add(key) + options.push({ label: `${p.User} โ€” ${p.AccessRights}`, value: key }) + } + }) + ) + return options + }, + }, + ], + customDataformatter: (rows, action, formData) => { + const rowArray = Array.isArray(rows) ? rows : [rows] + const selected = new Set((formData.permissionsToRemove ?? []).map((o) => o.value)) + // Group per tenant; only remove grants each mailbox actually has + const byTenant = {} + rowArray.forEach((mailbox) => { + const permissions = (mailbox.Permissions ?? []) + .filter((p) => selected.has(`${p.User}|${p.AccessRights}`)) + .map((p) => ({ + UserID: p.User, + PermissionLevel: p.AccessRights, + Modification: 'Remove', + })) + if (!permissions.length) return + ;(byTenant[mailbox.Tenant] ??= []).push({ userID: mailbox.MailboxUPN, permissions }) + }) + // Array return => one POST per tenant, so AllTenants selections work + return Object.entries(byTenant).map(([tenantFilter, mailboxRequests]) => ({ + mailboxRequests, + tenantFilter, + })) + }, + }, + ] + + const byUserActions = [ + { + label: 'Bulk Remove Mailbox Permissions', + type: 'POST', + url: '/api/ExecModifyMBPerms', + icon: , + confirmText: "Remove the selected users' permissions from the selected mailboxes?", + multiPost: false, + relatedQueryKeys, + fields: [ + { + type: 'autoComplete', + name: 'permissionsToRemove', + label: 'Mailbox permissions to remove', + multiple: true, + creatable: false, + required: true, + options: (rows) => { + const rowArray = Array.isArray(rows) ? rows : [rows] + const seen = new Set() + const options = [] + rowArray.forEach((row) => + (row?.Permissions ?? []).forEach((p) => { + const key = `${p.MailboxUPN}|${p.AccessRights}` + if (!seen.has(key)) { + seen.add(key) + options.push({ label: `${p.MailboxUPN} โ€” ${p.AccessRights}`, value: key }) + } + }) + ) + return options + }, + }, + ], + customDataformatter: (rows, action, formData) => { + const rowArray = Array.isArray(rows) ? rows : [rows] + const selected = new Set((formData.permissionsToRemove ?? []).map((o) => o.value)) + // Group per tenant; strip each selected user from the chosen mailboxes only + const byTenant = {} + rowArray.forEach((user) => { + ;(user.Permissions ?? []) + .filter((p) => selected.has(`${p.MailboxUPN}|${p.AccessRights}`)) + .forEach((p) => { + ;(byTenant[user.Tenant] ??= []).push({ + userID: p.MailboxUPN, + permissions: [ + { UserID: user.User, PermissionLevel: p.AccessRights, Modification: 'Remove' }, + ], + }) + }) + }) + // Array return => one POST per tenant, so AllTenants selections work + return Object.entries(byTenant).map(([tenantFilter, mailboxRequests]) => ({ + mailboxRequests, + tenantFilter, + })) + }, + }, + ] + const columns = byUser ? [ ...reportDB.cacheColumns.filter((c) => c === 'Tenant'), @@ -69,6 +194,7 @@ const Page = () => { queryKey={`${reportDB.resolvedQueryKey}-${byUser}`} apiData={{ ...reportDB.resolvedApiData, ByUser: byUser }} simpleColumns={columns} + actions={byUser ? byUserActions : byMailboxActions} cardButton={pageActions} offCanvas={null} /> diff --git a/src/pages/email/resources/management/list-rooms/edit.jsx b/src/pages/email/resources/management/list-rooms/edit.jsx index 9505c7e4c19b..859536d6dda7 100644 --- a/src/pages/email/resources/management/list-rooms/edit.jsx +++ b/src/pages/email/resources/management/list-rooms/edit.jsx @@ -1,8 +1,8 @@ import { useEffect } from "react"; -import { Box, Divider, IconButton, Tooltip, Typography } from "@mui/material"; +import { Alert, Box, Divider, IconButton, Tooltip, Typography } from "@mui/material"; import { Sync } from "@mui/icons-material"; import { Grid } from "@mui/system"; -import { useForm } from "react-hook-form"; +import { useForm, useWatch } from "react-hook-form"; import { Layout as DashboardLayout } from "../../../../../layouts/index.js"; import CippFormPage from "../../../../../components/CippFormPages/CippFormPage"; import CippFormComponent from "../../../../../components/CippComponents/CippFormComponent"; @@ -34,6 +34,34 @@ const automateProcessingOptions = [ { value: "AutoAccept", label: "AutoAccept - Accept and delete" }, ]; +const calendarPermissionOptions = [ + { value: "Owner", label: "Owner" }, + { value: "PublishingEditor", label: "Publishing Editor" }, + { value: "Editor", label: "Editor" }, + { value: "PublishingAuthor", label: "Publishing Author" }, + { value: "Author", label: "Author" }, + { value: "NonEditingAuthor", label: "Non Editing Author" }, + { value: "Reviewer", label: "Reviewer" }, + { value: "Contributor", label: "Contributor" }, + { value: "LimitedDetails", label: "Limited Details" }, + { value: "AvailabilityOnly", label: "Availability Only" }, + { value: "None", label: "None" }, +]; + +const getCalendarPermissionOption = (permission) => { + if (!permission) { + return null; + } + + const value = Array.isArray(permission) ? permission.join(",") : permission; + return ( + calendarPermissionOptions.find((option) => option.value === value) || { + value, + label: value, + } + ); +}; + const EditRoomMailbox = () => { const router = useRouter(); const { roomId } = router.query; @@ -41,6 +69,19 @@ const EditRoomMailbox = () => { const formControl = useForm({ mode: "onChange", }); + const addOrganizerToSubject = useWatch({ + control: formControl.control, + name: "AddOrganizerToSubject", + }); + const defaultCalendarPermission = useWatch({ + control: formControl.control, + name: "DefaultCalendarPermission", + }); + const defaultCalendarPermissionValue = + defaultCalendarPermission?.value || defaultCalendarPermission; + const showOrganizerVisibilityWarning = + Boolean(addOrganizerToSubject) && + ["AvailabilityOnly", "None"].includes(defaultCalendarPermissionValue); const roomInfo = ApiGetCall({ url: `/api/ListRooms?roomId=${roomId}&tenantFilter=${tenantDomain}`, @@ -92,8 +133,12 @@ const EditRoomMailbox = () => { ScheduleOnlyDuringWorkHours: room.ScheduleOnlyDuringWorkHours, AutomateProcessing: room.AutomateProcessing, AddOrganizerToSubject: room.AddOrganizerToSubject, + DeleteComments: room.DeleteComments, DeleteSubject: room.DeleteSubject, + RemovePrivateProperty: room.RemovePrivateProperty, RemoveCanceledMeetings: room.RemoveCanceledMeetings, + RemoveOldMeetingMessages: room.RemoveOldMeetingMessages, + DefaultCalendarPermission: getCalendarPermissionOption(room.DefaultCalendarPermission), // Calendar Configuration WorkDays: @@ -172,8 +217,13 @@ const EditRoomMailbox = () => { ScheduleOnlyDuringWorkHours: values.ScheduleOnlyDuringWorkHours, AutomateProcessing: values.AutomateProcessing?.value || values.AutomateProcessing, AddOrganizerToSubject: values.AddOrganizerToSubject, + DeleteComments: values.DeleteComments, DeleteSubject: values.DeleteSubject, + RemovePrivateProperty: values.RemovePrivateProperty, RemoveCanceledMeetings: values.RemoveCanceledMeetings, + RemoveOldMeetingMessages: values.RemoveOldMeetingMessages, + DefaultCalendarPermission: + values.DefaultCalendarPermission?.value || values.DefaultCalendarPermission, // Calendar Configuration WorkDays: values.WorkDays?.map((day) => day.value).join(","), @@ -326,6 +376,26 @@ const EditRoomMailbox = () => { formControl={formControl} /> + + + + {showOrganizerVisibilityWarning && ( + + + Users will not be able to see the organizer while Default calendar permissions are + set to Availability Only or None. Set Default to at least Limited Details to show + the organizer. + + + )} { formControl={formControl} /> + + + + + + { formControl={formControl} /> + + + {/* Working Hours */} diff --git a/src/pages/endpoint/MEM/devices/device/index.jsx b/src/pages/endpoint/MEM/devices/device/index.jsx index 6caa775a2616..079bc4307d46 100644 --- a/src/pages/endpoint/MEM/devices/device/index.jsx +++ b/src/pages/endpoint/MEM/devices/device/index.jsx @@ -1,9 +1,9 @@ -import { Layout as DashboardLayout } from "../../../../../layouts/index.js"; -import { useSettings } from "../../../../../hooks/use-settings"; -import { useRouter } from "next/router"; -import { ApiGetCall, ApiPostCall } from "../../../../../api/ApiCall"; -import CippFormSkeleton from "../../../../../components/CippFormPages/CippFormSkeleton"; -import CalendarIcon from "@heroicons/react/24/outline/CalendarIcon"; +import { Layout as DashboardLayout } from '../../../../../layouts/index.js' +import { useSettings } from '../../../../../hooks/use-settings' +import { useRouter } from 'next/router' +import { ApiGetCall, ApiPostCall } from '../../../../../api/ApiCall' +import CippFormSkeleton from '../../../../../components/CippFormPages/CippFormSkeleton' +import CalendarIcon from '@heroicons/react/24/outline/CalendarIcon' import { PhoneAndroid, Computer, @@ -14,144 +14,153 @@ import { CheckCircle, Warning, Sync, - RestartAlt, - LocationOn, - Password, - PasswordOutlined, - Key, - Edit, - FindInPage, - Shield, - Archive, - AutoMode, - Recycling, - ManageAccounts, Fingerprint, Group, -} from "@mui/icons-material"; -import { HeaderedTabbedLayout } from "../../../../../layouts/HeaderedTabbedLayout"; -import tabOptions from "./tabOptions"; -import { CippCopyToClipBoard } from "../../../../../components/CippComponents/CippCopyToClipboard"; -import { Box, Stack } from "@mui/system"; -import { Grid } from "@mui/system"; -import { SvgIcon, Typography, Card, CardHeader, Divider } from "@mui/material"; -import { CippBannerListCard } from "../../../../../components/CippCards/CippBannerListCard"; -import { CippTimeAgo } from "../../../../../components/CippComponents/CippTimeAgo"; -import { useEffect, useState, useRef } from "react"; -import { PropertyList } from "../../../../../components/property-list"; -import { PropertyListItem } from "../../../../../components/property-list-item"; -import { CippDataTable } from "../../../../../components/CippTable/CippDataTable"; -import { CippHead } from "../../../../../components/CippComponents/CippHead"; -import { Button } from "@mui/material"; -import { getCippFormatting } from "../../../../../utils/get-cipp-formatting"; -import { PencilIcon, EyeIcon } from "@heroicons/react/24/outline"; +} from '@mui/icons-material' +import { HeaderedTabbedLayout } from '../../../../../layouts/HeaderedTabbedLayout' +import tabOptions from './tabOptions' +import { CippCopyToClipBoard } from '../../../../../components/CippComponents/CippCopyToClipboard' +import { getIntuneDeviceActions } from '../../../../../components/CippComponents/CippIntuneDeviceActions.jsx' +import { Box, Stack } from '@mui/system' +import { Grid } from '@mui/system' +import { SvgIcon, Typography, Card, CardHeader, Divider, Tooltip, IconButton } from '@mui/material' +import { CippBannerListCard } from '../../../../../components/CippCards/CippBannerListCard' +import { CippTimeAgo } from '../../../../../components/CippComponents/CippTimeAgo' +import { useEffect, useState, useRef } from 'react' +import { PropertyList } from '../../../../../components/property-list' +import { PropertyListItem } from '../../../../../components/property-list-item' +import { CippDataTable } from '../../../../../components/CippTable/CippDataTable' +import { CippHead } from '../../../../../components/CippComponents/CippHead' +import { Button } from '@mui/material' +import { getCippFormatting } from '../../../../../utils/get-cipp-formatting' +import { PencilIcon, EyeIcon } from '@heroicons/react/24/outline' const Page = () => { - const userSettingsDefaults = useSettings(); - const router = useRouter(); - const { deviceId } = router.query; - const [waiting, setWaiting] = useState(false); + const userSettingsDefaults = useSettings() + const router = useRouter() + const { deviceId } = router.query + const [waiting, setWaiting] = useState(false) useEffect(() => { if (deviceId) { - setWaiting(true); + setWaiting(true) } - }, [deviceId]); + }, [deviceId]) const deviceRequest = ApiGetCall({ - url: "/api/ListGraphRequest", + url: '/api/ListGraphRequest', data: { Endpoint: `deviceManagement/managedDevices/${deviceId}`, tenantFilter: router.query.tenantFilter ?? userSettingsDefaults.currentTenant, }, queryKey: `ManagedDevice-${deviceId}`, waiting: waiting, - }); + }) const deviceBulkRequest = ApiPostCall({ urlFromData: true, - }); + }) // Handle response structure - ListGraphRequest may wrap single items in Results array // Try Results array first, then Results as object, then data directly - let deviceData = null; + let deviceData = null if (deviceRequest.isSuccess && deviceRequest.data) { if (Array.isArray(deviceRequest.data.Results)) { - deviceData = deviceRequest.data.Results[0]; + deviceData = deviceRequest.data.Results[0] } else if (deviceRequest.data.Results) { - deviceData = deviceRequest.data.Results; + deviceData = deviceRequest.data.Results } else { - deviceData = deviceRequest.data; + deviceData = deviceRequest.data } } function refreshFunction() { - if (!deviceId) return; + if (!deviceId) return const requests = [ { - id: "deviceCompliance", + id: 'deviceCompliance', url: `/deviceManagement/managedDevices/${deviceId}/deviceCompliancePolicyStates`, - method: "GET", + method: 'GET', }, { - id: "deviceConfiguration", + id: 'deviceConfiguration', url: `/deviceManagement/managedDevices/${deviceId}/deviceConfigurationStates`, - method: "GET", + method: 'GET', }, { - id: "detectedApps", + id: 'detectedApps', url: `/deviceManagement/managedDevices/${deviceId}/detectedApps`, - method: "GET", + method: 'GET', }, { - id: "users", + id: 'users', url: `/deviceManagement/managedDevices/${deviceId}/users`, - method: "GET", + method: 'GET', }, - { - id: "deviceMemberOf", - url: `/devices/${deviceId}/transitiveMemberOf/microsoft.graph.group`, - method: "GET", - }, - ]; + ] + + // /devices/{id} requires the Entra directory object, not the Intune managedDevice id, + // so address it by alternate key. All-zeros means the device is not Entra-joined. + const azureADDeviceId = deviceData?.azureADDeviceId + if (azureADDeviceId && azureADDeviceId !== '00000000-0000-0000-0000-000000000000') { + requests.push({ + id: 'deviceMemberOf', + // No /microsoft.graph.group cast: OData cast is an advanced query needing + // ConsistencyLevel=eventual + $count, and silently returns [] without them. + // Groups are filtered client-side on @odata.type below. + url: `/devices(deviceId='${azureADDeviceId}')/transitiveMemberOf`, + method: 'GET', + }) + } deviceBulkRequest.mutate({ - url: "/api/ListGraphBulkRequest", + url: '/api/ListGraphBulkRequest', data: { Requests: requests, tenantFilter: userSettingsDefaults.currentTenant, }, - }); + }) } useEffect(() => { - if (deviceId && userSettingsDefaults.currentTenant && !deviceBulkRequest.isSuccess) { - refreshFunction(); + // Wait for deviceRequest so azureADDeviceId is available for the memberOf request + if ( + deviceId && + userSettingsDefaults.currentTenant && + deviceRequest.isSuccess && + !deviceBulkRequest.isSuccess + ) { + refreshFunction() } - }, [deviceId, userSettingsDefaults.currentTenant, deviceBulkRequest.isSuccess]); + }, [ + deviceId, + userSettingsDefaults.currentTenant, + deviceRequest.isSuccess, + deviceBulkRequest.isSuccess, + ]) - const bulkData = deviceBulkRequest?.data?.data ?? []; - const deviceComplianceData = bulkData?.find((item) => item.id === "deviceCompliance"); - const deviceConfigurationData = bulkData?.find((item) => item.id === "deviceConfiguration"); - const detectedAppsData = bulkData?.find((item) => item.id === "detectedApps"); - const usersData = bulkData?.find((item) => item.id === "users"); - const deviceMemberOfData = bulkData?.find((item) => item.id === "deviceMemberOf"); + const bulkData = deviceBulkRequest?.data?.data ?? [] + const deviceComplianceData = bulkData?.find((item) => item.id === 'deviceCompliance') + const deviceConfigurationData = bulkData?.find((item) => item.id === 'deviceConfiguration') + const detectedAppsData = bulkData?.find((item) => item.id === 'detectedApps') + const usersData = bulkData?.find((item) => item.id === 'users') + const deviceMemberOfData = bulkData?.find((item) => item.id === 'deviceMemberOf') - const deviceCompliance = deviceComplianceData?.body?.value || []; - const deviceConfiguration = deviceConfigurationData?.body?.value || []; - const detectedApps = detectedAppsData?.body?.value || []; - const users = usersData?.body?.value || []; - const deviceMemberOf = deviceMemberOfData?.body?.value || []; + const deviceCompliance = deviceComplianceData?.body?.value || [] + const deviceConfiguration = deviceConfigurationData?.body?.value || [] + const detectedApps = detectedAppsData?.body?.value || [] + const users = usersData?.body?.value || [] + const deviceMemberOf = deviceMemberOfData?.body?.value || [] // Helper function to format bytes to GB (matching getCippFormatting pattern) const formatBytesToGB = (bytes) => { - if (!bytes || bytes === 0) return "N/A"; - const gb = bytes / 1024 / 1024 / 1024; - return `${gb.toFixed(2)} GB`; - }; + if (!bytes || bytes === 0) return 'N/A' + const gb = bytes / 1024 / 1024 / 1024 + return `${gb.toFixed(2)} GB` + } // Set the title and subtitle for the layout - const title = deviceRequest.isSuccess ? deviceData?.deviceName : "Loading..."; + const title = deviceRequest.isSuccess ? deviceData?.deviceName : 'Loading...' const subtitle = deviceRequest.isSuccess ? [ @@ -172,7 +181,7 @@ const Page = () => { ), }, { - icon: , + icon: , text: ( ), table: { - title: "Mailbox Permissions", + title: 'Mailbox Permissions', hideTitle: true, data: userRequest.data?.[0]?.Permissions?.map((permission) => { - const userIdentifier = permission?.User; - const permissionInfo = getPermissionInfo(permission.User, groupsList); + const userIdentifier = permission?.User + const permissionInfo = getPermissionInfo(permission.User, groupsList) return { User: permissionInfo.displayName, // Show clean name AccessRights: permission.AccessRights, Type: permissionInfo.type, _raw: permission, - }; + } }) || [], refreshFunction: () => userRequest.refetch(), isFetching: userRequest.isFetching, - simpleColumns: ["User", "AccessRights", "Type"], + simpleColumns: ['User', 'AccessRights', 'Type'], actions: mailboxPermissionActions, offCanvas: { children: (data) => { - const originalUser = data?._raw?.User || data?.User; - const permissionInfo = getPermissionInfo(originalUser, groupsList); + const originalUser = data?._raw?.User || data?.User + const permissionInfo = getPermissionInfo(originalUser, groupsList) return ( - ); + ) }, }, }, }, - ]; + ] + + const mailboxAccessActions = [ + { + label: 'Remove Permission', + type: 'POST', + icon: , + url: '/api/ExecModifyMBPerms', + customDataformatter: (row, action, formData) => { + const rowArray = Array.isArray(row) ? row : [row] + return { + mailboxRequests: rowArray.map((r) => ({ + userID: r.MailboxUPN, + permissions: [ + { + UserID: graphUserRequest.data?.[0]?.userPrincipalName, + PermissionLevel: r.AccessRights, + Modification: 'Remove', + }, + ], + })), + tenantFilter: userSettingsDefaults.currentTenant, + } + }, + confirmText: "Are you sure you want to remove this user's access to the selected mailboxes?", + multiPost: false, + relatedQueryKeys: [`MailboxAccess-${userId}`], + }, + ] + + const mailboxAccessData = mailboxAccessRequest.data?.[0]?.Permissions ?? [] + + const mailboxAccessCard = [ + { + id: 1, + cardLabelBox: { + cardLabelBoxHeader: mailboxAccessRequest.isFetching ? ( + + ) : mailboxAccessData.length !== 0 ? ( + + ) : ( + + ), + }, + text: 'Mailbox Access', + subtext: mailboxAccessRequest.isError + ? 'Could not load the cached permission report - sync the mailbox permissions cache and try again' + : mailboxAccessData.length !== 0 + ? 'This user has access to other mailboxes (from the cached permission report)' + : 'This user has no access to other mailboxes (from the cached permission report)', + statusColor: 'green.main', + table: { + title: 'Mailbox Access', + hideTitle: true, + data: mailboxAccessData, + refreshFunction: () => mailboxAccessRequest.refetch(), + isFetching: mailboxAccessRequest.isFetching, + simpleColumns: ['Mailbox', 'MailboxUPN', 'AccessRights'], + actions: mailboxAccessActions, + }, + }, + ] // Replace your existing calCard array with this simple version: const calCard = [ @@ -666,12 +759,12 @@ const Page = () => { ), }, - text: "Calendar permissions", + text: 'Calendar permissions', subtext: calPermissions.data?.length !== 0 - ? "Other users or groups have access to this calendar" - : "No other users or groups have access to this calendar", - statusColor: "green.main", + ? 'Other users or groups have access to this calendar' + : 'No other users or groups have access to this calendar', + statusColor: 'green.main', cardLabelBoxActions: ( ), table: { - title: "Calendar Permissions", + title: 'Calendar Permissions', hideTitle: true, data: calPermissions.data?.map((permission) => { - const userIdentifier = permission?.User; - const permissionInfo = getPermissionInfo(permission.User, groupsList); + const userIdentifier = permission?.User + const permissionInfo = getPermissionInfo(permission.User, groupsList) return { User: permissionInfo.displayName, - AccessRights: permission?.AccessRights?.join(", ") || "Unknown", - FolderName: permission?.FolderName || "Unknown", + AccessRights: permission?.AccessRights?.join(', ') || 'Unknown', + FolderName: permission?.FolderName || 'Unknown', Type: permissionInfo.type, _raw: permission, - }; + } }) || [], refreshFunction: () => calPermissions.refetch(), isFetching: calPermissions.isFetching, - simpleColumns: ["User", "AccessRights", "FolderName", "Type"], + simpleColumns: ['User', 'AccessRights', 'FolderName', 'Type'], actions: [ { - label: "Remove Permission", - type: "POST", + label: 'Remove Permission', + type: 'POST', icon: , - url: "/api/ExecModifyCalPerms", + url: '/api/ExecModifyCalPerms', customDataformatter: (row, action, formData) => { - var permissions = []; + var permissions = [] if (Array.isArray(row)) { row.forEach((item) => { - const originalUser = item._raw ? item._raw.User : item.User; + const originalUser = item._raw ? item._raw.User : item.User permissions.push({ UserID: originalUser, // Use original identifier for API calls PermissionLevel: item.AccessRights, FolderName: item.FolderName, - Modification: "Remove", - }); - }); + Modification: 'Remove', + }) + }) } else { - const originalUser = row._raw ? row._raw.User : row.User; + const originalUser = row._raw ? row._raw.User : row.User permissions.push({ UserID: originalUser, // Use original identifier for API calls PermissionLevel: row.AccessRights, FolderName: row.FolderName, - Modification: "Remove", - }); + Modification: 'Remove', + }) } return { userID: graphUserRequest.data?.[0]?.userPrincipalName, tenantFilter: userSettingsDefaults.currentTenant, permissions: permissions, - }; + } }, - confirmText: "Are you sure you want to remove this calendar permission?", + confirmText: 'Are you sure you want to remove this calendar permission?', multiPost: false, relatedQueryKeys: `CalendarPermissions-${userId}`, - condition: (row) => row.User !== "Default" && row.User !== "Anonymous", + condition: (row) => row.User !== 'Default' && row.User !== 'Anonymous', }, ], offCanvas: { children: (data) => { - const originalUser = data._raw ? data._raw.User : data.User; - const permissionInfo = getPermissionInfo(originalUser, groupsList); + const originalUser = data._raw ? data._raw.User : data.User + const permissionInfo = getPermissionInfo(originalUser, groupsList) return ( , - url: "/api/ExecModifyCalPerms", + url: '/api/ExecModifyCalPerms', data: { userID: graphUserRequest.data?.[0]?.userPrincipalName, tenantFilter: userSettingsDefaults.currentTenant, @@ -780,22 +873,22 @@ const Page = () => { UserID: originalUser, // Use original identifier for API calls PermissionLevel: data.AccessRights, FolderName: data.FolderName, - Modification: "Remove", + Modification: 'Remove', }, ], }, - confirmText: "Are you sure you want to remove this calendar permission?", + confirmText: 'Are you sure you want to remove this calendar permission?', multiPost: false, relatedQueryKeys: `CalendarPermissions-${userId}`, }, ]} /> - ); + ) }, }, }, }, - ]; + ] const contactCard = [ { @@ -809,12 +902,12 @@ const Page = () => { ), }, - text: "Contact permissions", + text: 'Contact permissions', subtext: contactPermissions.data?.length !== 0 - ? "Other users or groups have access to this contact folder" - : "No other users or groups have access to this contact folder", - statusColor: "green.main", + ? 'Other users or groups have access to this contact folder' + : 'No other users or groups have access to this contact folder', + statusColor: 'green.main', cardLabelBoxActions: ( ), table: { - title: "Contact Permissions", + title: 'Contact Permissions', hideTitle: true, data: contactPermissions.data?.map((permission) => { - const userIdentifier = permission?.User; - const permissionInfo = getPermissionInfo(permission.User, groupsList); + const userIdentifier = permission?.User + const permissionInfo = getPermissionInfo(permission.User, groupsList) return { User: permissionInfo.displayName, - AccessRights: permission?.AccessRights?.join(", ") || "Unknown", - FolderName: permission?.FolderName || "Unknown", + AccessRights: permission?.AccessRights?.join(', ') || 'Unknown', + FolderName: permission?.FolderName || 'Unknown', Type: permissionInfo.type, _raw: permission, - }; + } }) || [], refreshFunction: () => contactPermissions.refetch(), isFetching: contactPermissions.isFetching, - simpleColumns: ["User", "AccessRights", "FolderName", "Type"], + simpleColumns: ['User', 'AccessRights', 'FolderName', 'Type'], actions: [ { - label: "Remove Permission", - type: "POST", + label: 'Remove Permission', + type: 'POST', icon: , - url: "/api/ExecModifyContactPerms", + url: '/api/ExecModifyContactPerms', customDataformatter: (row, action, formData) => { - var permissions = []; + var permissions = [] if (Array.isArray(row)) { row.forEach((item) => { - const originalUser = item._raw ? item._raw.User : item.User; + const originalUser = item._raw ? item._raw.User : item.User permissions.push({ UserID: originalUser, // Use original identifier for API calls PermissionLevel: item.AccessRights, FolderName: item.FolderName, - Modification: "Remove", - }); - }); + Modification: 'Remove', + }) + }) } else { - const originalUser = row._raw ? row._raw.User : row.User; + const originalUser = row._raw ? row._raw.User : row.User permissions.push({ UserID: originalUser, // Use original identifier for API calls PermissionLevel: row.AccessRights, FolderName: row.FolderName, - Modification: "Remove", - }); + Modification: 'Remove', + }) } return { userID: graphUserRequest.data?.[0]?.userPrincipalName, tenantFilter: userSettingsDefaults.currentTenant, permissions: permissions, - }; + } }, - confirmText: "Are you sure you want to remove this contact permission?", + confirmText: 'Are you sure you want to remove this contact permission?', multiPost: false, relatedQueryKeys: `ContactPermissions-${userId}`, - condition: (row) => row.User !== "Default" && row.User !== "Anonymous", + condition: (row) => row.User !== 'Default' && row.User !== 'Anonymous', }, ], offCanvas: { children: (data) => { - const originalUser = data._raw ? data._raw.User : data.User; - const permissionInfo = getPermissionInfo(originalUser, groupsList); + const originalUser = data._raw ? data._raw.User : data.User + const permissionInfo = getPermissionInfo(originalUser, groupsList) return ( , - url: "/api/ExecModifyContactPerms", + url: '/api/ExecModifyContactPerms', data: { userID: graphUserRequest.data?.[0]?.userPrincipalName, tenantFilter: userSettingsDefaults.currentTenant, @@ -923,84 +1016,84 @@ const Page = () => { UserID: originalUser, // Use original identifier for API calls PermissionLevel: data.AccessRights, FolderName: data.FolderName, - Modification: "Remove", + Modification: 'Remove', }, ], }, - confirmText: "Are you sure you want to remove this contact permission?", + confirmText: 'Are you sure you want to remove this contact permission?', multiPost: false, relatedQueryKeys: `ContactPermissions-${userId}`, }, ]} /> - ); + ) }, }, }, }, - ]; + ] const mailboxRuleActions = [ { - label: "Enable Mailbox Rule", - type: "POST", + label: 'Enable Mailbox Rule', + type: 'POST', icon: , - url: "/api/ExecSetMailboxRule", + url: '/api/ExecSetMailboxRule', customDataformatter: (row, action, formData) => { - const rows = Array.isArray(row) ? row : [row]; + const rows = Array.isArray(row) ? row : [row] const result = rows.map((r) => ({ ruleId: r?.Identity, userPrincipalName: graphUserRequest.data?.[0]?.userPrincipalName, ruleName: r?.Name, Enable: true, tenantFilter: userSettingsDefaults.currentTenant, - })); - return Array.isArray(row) ? result : result[0]; + })) + return Array.isArray(row) ? result : result[0] }, condition: (row) => row && !row.Enabled, - confirmText: "Are you sure you want to enable this mailbox rule?", + confirmText: 'Are you sure you want to enable this mailbox rule?', multiPost: false, }, { - label: "Disable Mailbox Rule", - type: "POST", + label: 'Disable Mailbox Rule', + type: 'POST', icon: , - url: "/api/ExecSetMailboxRule", + url: '/api/ExecSetMailboxRule', customDataformatter: (row, action, formData) => { - const rows = Array.isArray(row) ? row : [row]; + const rows = Array.isArray(row) ? row : [row] const result = rows.map((r) => ({ ruleId: r?.Identity, userPrincipalName: graphUserRequest.data?.[0]?.userPrincipalName, ruleName: r?.Name, Disable: true, tenantFilter: userSettingsDefaults.currentTenant, - })); - return Array.isArray(row) ? result : result[0]; + })) + return Array.isArray(row) ? result : result[0] }, condition: (row) => row && row.Enabled, - confirmText: "Are you sure you want to disable this mailbox rule?", + confirmText: 'Are you sure you want to disable this mailbox rule?', multiPost: false, }, { - label: "Remove Mailbox Rule", - type: "POST", + label: 'Remove Mailbox Rule', + type: 'POST', icon: , - url: "/api/ExecRemoveMailboxRule", + url: '/api/ExecRemoveMailboxRule', customDataformatter: (row, action, formData) => { - const rows = Array.isArray(row) ? row : [row]; + const rows = Array.isArray(row) ? row : [row] const result = rows.map((r) => ({ ruleId: r?.Identity, ruleName: r?.Name, userPrincipalName: graphUserRequest.data?.[0]?.userPrincipalName, tenantFilter: userSettingsDefaults.currentTenant, - })); - return Array.isArray(row) ? result : result[0]; + })) + return Array.isArray(row) ? result : result[0] }, - confirmText: "Are you sure you want to remove this mailbox rule?", + confirmText: 'Are you sure you want to remove this mailbox rule?', multiPost: false, relatedQueryKeys: `MailboxRules-${userId}`, }, - ]; + ] const mailboxRulesCard = [ { @@ -1014,33 +1107,33 @@ const Page = () => { ), }, - text: "Current Mailbox Rules", + text: 'Current Mailbox Rules', subtext: mailboxRulesRequest.data?.length - ? "Mailbox rules are configured for this user" - : "No mailbox rules configured for this user", - statusColor: "green.main", + ? 'Mailbox rules are configured for this user' + : 'No mailbox rules configured for this user', + statusColor: 'green.main', table: { - title: "Mailbox Rules", + title: 'Mailbox Rules', hideTitle: true, data: mailboxRulesRequest.data || [], refreshFunction: () => mailboxRulesRequest.refetch(), isFetching: mailboxRulesRequest.isFetching, - simpleColumns: ["Enabled", "Name", "Description", "Priority"], + simpleColumns: ['Enabled', 'Name', 'Description', 'Priority'], actions: mailboxRuleActions, offCanvas: { children: (data) => { const keys = Object.keys(data).filter( - (key) => !key.includes("@odata") && !key.includes("@data"), - ); - const properties = []; + (key) => !key.includes('@odata') && !key.includes('@data') + ) + const properties = [] keys.forEach((key) => { if (data[key] && data[key].length > 0) { properties.push({ label: getCippTranslation(key), value: getCippFormatting(data[key], key), - }); + }) } - }); + }) return ( { propertyItems={properties} actionItems={[ { - label: "Enable Mailbox Rule", - type: "POST", + label: 'Enable Mailbox Rule', + type: 'POST', icon: , - url: "/api/ExecSetMailboxRule", + url: '/api/ExecSetMailboxRule', data: { ruleId: data?.Identity, userPrincipalName: graphUserRequest.data?.[0]?.userPrincipalName, @@ -1059,14 +1152,14 @@ const Page = () => { Enable: true, tenantFilter: userSettingsDefaults.currentTenant, }, - confirmText: "Are you sure you want to enable this mailbox rule?", + confirmText: 'Are you sure you want to enable this mailbox rule?', multiPost: false, }, { - label: "Disable Mailbox Rule", - type: "POST", + label: 'Disable Mailbox Rule', + type: 'POST', icon: , - url: "/api/ExecSetMailboxRule", + url: '/api/ExecSetMailboxRule', data: { ruleId: data?.Identity, userPrincipalName: graphUserRequest.data?.[0]?.userPrincipalName, @@ -1074,53 +1167,53 @@ const Page = () => { Disable: true, tenantFilter: userSettingsDefaults.currentTenant, }, - confirmText: "Are you sure you want to disable this mailbox rule?", + confirmText: 'Are you sure you want to disable this mailbox rule?', multiPost: false, }, { - label: "Remove Mailbox Rule", - type: "POST", + label: 'Remove Mailbox Rule', + type: 'POST', icon: , - url: "/api/ExecRemoveMailboxRule", + url: '/api/ExecRemoveMailboxRule', data: { ruleId: data?.Identity, ruleName: data?.Name, userPrincipalName: graphUserRequest.data?.[0]?.userPrincipalName, tenantFilter: userSettingsDefaults.currentTenant, }, - confirmText: "Are you sure you want to remove this mailbox rule?", + confirmText: 'Are you sure you want to remove this mailbox rule?', multiPost: false, relatedQueryKeys: `MailboxRules-${userId}`, }, ]} /> - ); + ) }, }, }, }, - ]; + ] const junkEmailConfigActions = [ { - label: "Remove Entry", - type: "POST", + label: 'Remove Entry', + type: 'POST', icon: , - url: "/api/RemoveTrustedBlockedSender", + url: '/api/RemoveTrustedBlockedSender', customDataformatter: (row, action, formData) => { return { userPrincipalName: row?.UserPrincipalName, typeProperty: row?.TypeProperty, value: row?.Value, tenantFilter: userSettingsDefaults.currentTenant, - }; + } }, confirmText: - "Are you sure you want to remove [Value] from the [Type] list for [UserPrincipalName]?", + 'Are you sure you want to remove [Value] from the [Type] list for [UserPrincipalName]?', multiPost: false, relatedQueryKeys: `JunkEmailConfig-${userId}`, }, - ]; + ] const junkEmailConfigCard = [ { @@ -1134,18 +1227,18 @@ const Page = () => { ), }, - text: "Trusted and Blocked Senders/Domains", + text: 'Trusted and Blocked Senders/Domains', subtext: junkEmailConfigRequest.data?.length - ? "Trusted/Blocked senders and domains are configured for this user" - : "No trusted or blocked senders/domains entries for this user", - statusColor: "green.main", + ? 'Trusted/Blocked senders and domains are configured for this user' + : 'No trusted or blocked senders/domains entries for this user', + statusColor: 'green.main', table: { - title: "Trusted and Blocked Senders/Domains", + title: 'Trusted and Blocked Senders/Domains', hideTitle: true, data: junkEmailConfigRequest.data || [], refreshFunction: () => junkEmailConfigRequest.refetch(), isFetching: junkEmailConfigRequest.isFetching, - simpleColumns: ["Type", "Value"], + simpleColumns: ['Type', 'Value'], actions: junkEmailConfigActions, offCanvas: { children: (data) => { @@ -1155,59 +1248,106 @@ const Page = () => { title="Entry Details" propertyItems={[ { - label: "Type", + label: 'Type', value: data.Type, }, { - label: "Value", + label: 'Value', value: data.Value, }, { - label: "Property", + label: 'Property', value: data.TypeProperty, }, ]} actionItems={junkEmailConfigActions} /> - ); + ) }, }, }, }, - ]; + ] const proxyAddressActions = [ { - label: "Make Primary", - type: "POST", + label: 'Make Primary', + type: 'POST', icon: , - url: "/api/EditUserAliases", + url: '/api/EditUserAliases', data: { id: userId, tenantFilter: userSettingsDefaults.currentTenant, - MakePrimary: "Address", + MakePrimary: 'Address', }, - confirmText: "Are you sure you want to make this the primary proxy address?", + confirmText: 'Are you sure you want to make this the primary proxy address?', multiPost: false, - relatedQueryKeys: `ListUsers-${userId}`, - condition: (row) => row && row.Type !== "Primary", + relatedQueryKeys: [`ListUsers-${userId}`, `Mailbox-${userId}`], + condition: (row) => row && row.Type === 'Alias', }, { - label: "Remove Proxy Address", - type: "POST", + label: 'Remove Proxy Address', + type: 'POST', icon: , - url: "/api/EditUserAliases", + url: '/api/EditUserAliases', data: { id: userId, tenantFilter: userSettingsDefaults.currentTenant, - RemovedAliases: "Address", + RemovedAliases: 'Address', }, - confirmText: "Are you sure you want to remove this proxy address?", + confirmText: 'Are you sure you want to remove this proxy address?', multiPost: false, - relatedQueryKeys: `ListUsers-${userId}`, - condition: (row) => row && row.Type !== "Primary", + relatedQueryKeys: [`ListUsers-${userId}`, `Mailbox-${userId}`], + condition: (row) => row && row.Type === 'Alias', }, - ]; + ] + + // Merge Entra ID proxyAddresses (fast, primary source) with the mailbox EmailAddresses + // from Exchange so forward-sync drift between the two directories is visible. + const graphProxyAddresses = (graphUserRequest.data?.[0]?.proxyAddresses || []).filter( + (address) => typeof address === 'string' + ) + // Mailbox serializes as an array with one element + const mailboxDetails = [].concat(userRequest.data?.[0]?.Mailbox || [])[0] + const exchangeProxyAddresses = [] + .concat(mailboxDetails?.EmailAddresses || []) + .filter((address) => typeof address === 'string') + // Only assess sync when Exchange returned addresses; a mailbox always has at least a primary + const exchangeAddressesLoaded = userRequest.isSuccess && exchangeProxyAddresses.length > 0 + const proxyAddressType = (address) => + address.startsWith('SMTP:') + ? 'Primary' + : address.startsWith('smtp:') + ? 'Alias' + : address.split(':')[0] + const proxyAddressRows = (() => { + const rows = new Map() + graphProxyAddresses.forEach((address) => { + rows.set(address.toLowerCase(), { Address: address, inGraph: true, inExchange: false }) + }) + exchangeProxyAddresses.forEach((address) => { + const existing = rows.get(address.toLowerCase()) + if (existing) { + existing.inExchange = true + } else { + rows.set(address.toLowerCase(), { Address: address, inGraph: false, inExchange: true }) + } + }) + return [...rows.values()].map((row) => ({ + Address: row.Address, + Type: proxyAddressType(row.Address), + Source: !exchangeAddressesLoaded + ? 'Checking' + : row.inGraph && row.inExchange + ? 'Entra ID & Exchange' + : row.inGraph + ? 'Entra ID only' + : 'Exchange only', + })) + })() + const mismatchedAddresses = exchangeAddressesLoaded + ? proxyAddressRows.filter((row) => row.Source !== 'Entra ID & Exchange') + : [] const proxyAddressesCard = [ { @@ -1215,18 +1355,21 @@ const Page = () => { cardLabelBox: { cardLabelBoxHeader: graphUserRequest.isFetching ? ( - ) : graphUserRequest.data?.[0]?.proxyAddresses?.length > 1 ? ( + ) : mismatchedAddresses.length === 0 && + graphUserRequest.data?.[0]?.proxyAddresses?.length > 1 ? ( ) : ( ), }, - text: "Proxy Addresses", + text: 'Proxy Addresses', subtext: - graphUserRequest.data?.[0]?.proxyAddresses?.length > 1 - ? "Proxy addresses are configured for this user" - : "No proxy addresses configured for this user", - statusColor: "green.main", + mismatchedAddresses.length > 0 + ? `${mismatchedAddresses.length} address(es) only exist in one of Entra ID or Exchange - Microsoft may still be propagating recent changes` + : graphUserRequest.data?.[0]?.proxyAddresses?.length > 1 + ? 'Proxy addresses are configured for this user' + : 'No proxy addresses configured for this user', + statusColor: 'green.main', cardLabelBoxActions: ( ), table: { - title: "Proxy Addresses", + title: 'Proxy Addresses', hideTitle: true, - data: - graphUserRequest.data?.[0]?.proxyAddresses?.map((address) => ({ - Address: address, - Type: typeof address === "string" && address.startsWith("SMTP:") ? "Primary" : "Alias", - })) || [], - refreshFunction: () => graphUserRequest.refetch(), + data: proxyAddressRows, + refreshFunction: () => { + graphUserRequest.refetch() + userRequest.refetch() + }, isFetching: graphUserRequest.isFetching, - simpleColumns: ["Address", "Type"], + simpleColumns: ['Address', 'Type', 'Source'], actions: proxyAddressActions, offCanvas: { children: (data) => { @@ -1258,22 +1400,26 @@ const Page = () => { title="Address Details" propertyItems={[ { - label: "Address", + label: 'Address', value: data.Address, }, { - label: "Type", + label: 'Type', value: data.Type, }, + { + label: 'Source', + value: data.Source, + }, ]} actionItems={proxyAddressActions} /> - ); + ) }, }, }, }, - ]; + ] return ( { {userRequest?.data?.[0]?.Mailbox?.[0]?.error.includes( - "Microsoft.Exchange.Configuration.Tasks.ManagementObjectNotFoundException", + 'Microsoft.Exchange.Configuration.Tasks.ManagementObjectNotFoundException' ) - ? "This user does not have a mailbox, make sure they are licensed for Exchange." - : "An error occurred while fetching the mailbox details."} + ? 'This user does not have a mailbox, make sure they are licensed for Exchange.' + : 'An error occurred while fetching the mailbox details.'} @@ -1320,7 +1466,7 @@ const Page = () => { )} {!userRequest?.data?.[0]?.Mailbox?.[0]?.error?.includes( - "Microsoft.Exchange.Configuration.Tasks.ManagementObjectNotFoundException", + 'Microsoft.Exchange.Configuration.Tasks.ManagementObjectNotFoundException' ) && ( <> @@ -1343,6 +1489,11 @@ const Page = () => { items={permissions} isCollapsible={true} /> + { formHook={formHook} combinedOptions={mailboxPermissionOptions} isUserGroupLoading={isUserGroupLoading} + includeGroups={includeSecurityGroups} + onIncludeGroupsChange={setIncludeSecurityGroups} defaultAutoMap={true} /> )} @@ -1424,6 +1577,8 @@ const Page = () => { formHook={formHook} combinedOptions={calendarPermissionOptions} isUserGroupLoading={isUserGroupLoading} + includeGroups={includeSecurityGroups} + onIncludeGroupsChange={setIncludeSecurityGroups} /> )} @@ -1440,13 +1595,15 @@ const Page = () => { formHook={formHook} combinedOptions={contactPermissionOptions} isUserGroupLoading={isUserGroupLoading} + includeGroups={includeSecurityGroups} + onIncludeGroupsChange={setIncludeSecurityGroups} /> )} - ); -}; + ) +} -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page} -export default Page; +export default Page diff --git a/src/pages/identity/administration/vacation-mode/add/index.js b/src/pages/identity/administration/vacation-mode/add/index.js index 5c9ce854da99..1c8064b9d423 100644 --- a/src/pages/identity/administration/vacation-mode/add/index.js +++ b/src/pages/identity/administration/vacation-mode/add/index.js @@ -69,6 +69,8 @@ const Page = () => { enableCAExclusion: false, PolicyId: [], excludeLocationAuditAlerts: false, + createTravelPolicy: false, + travelCountries: [], enableMailboxPermissions: false, delegates: [], permissionTypes: [], diff --git a/src/pages/identity/administration/vacation-mode/index.js b/src/pages/identity/administration/vacation-mode/index.js index 0e2b20cbd3a9..69a79cae1d74 100644 --- a/src/pages/identity/administration/vacation-mode/index.js +++ b/src/pages/identity/administration/vacation-mode/index.js @@ -6,6 +6,7 @@ import { Button } from "@mui/material"; import Link from "next/link"; import { EventAvailable } from "@mui/icons-material"; import { useSettings } from "../../../../hooks/use-settings.js"; +import ScheduledTaskDetails from "../../../../components/CippComponents/ScheduledTaskDetails"; const Page = () => { const initialState = useSettings(); @@ -90,14 +91,10 @@ const Page = () => { simpleColumns={["Tenant", "Name", "Reference", "TaskState", "ScheduledTime", "ExecutedTime"]} filters={filterList} offCanvas={{ - extendedInfoFields: [ - "Name", - "TaskState", - "ScheduledTime", - "Reference", - "Tenant", - "ExecutedTime", - ], + children: (extendedData) => ( + + ), + size: "xl", actions: actions, }} /> diff --git a/src/pages/identity/reports/azure-ad-connect-report/index.js b/src/pages/identity/reports/azure-ad-connect-report/index.js index 5a75dd7a60d9..b308565afa39 100644 --- a/src/pages/identity/reports/azure-ad-connect-report/index.js +++ b/src/pages/identity/reports/azure-ad-connect-report/index.js @@ -13,7 +13,7 @@ const apiUrl = "/api/ListAzureADConnectStatus"; const Page = () => { return ( { confirmText: 'Are you sure you want to create a template based on this sensitivity label?', hideBulk: true, }, + { + label: 'Set Label Color', + type: 'POST', + icon: , + url: '/api/EditSensitivityLabel', + data: { + Identity: 'Guid', + }, + fields: [ + { + label: 'Label Color', + // Dot notation so the value posts as parameters.AdvancedSettings.color, the shape + // EditSensitivityLabel passes straight to Set-Label. Submitting an empty value clears + // a previously set custom color. + name: 'parameters.AdvancedSettings.color', + type: 'colorPicker', + }, + ], + confirmText: + 'Pick a custom color for this sensitivity label. This supports any hex color, beyond the preset palette available in the Purview portal. Leave empty to clear the custom color.', + hideBulk: true, + }, { label: 'Delete Label', type: 'POST', @@ -46,6 +68,7 @@ const Page = () => { 'Tooltip', 'ParentId', 'ContentType', + 'Color', 'EncryptionEnabled', 'EncryptionProtectionType', 'ContentMarkingHeaderEnabled', @@ -62,6 +85,7 @@ const Page = () => { const simpleColumns = [ 'DisplayName', 'Name', + 'Color', 'ContentType', 'EncryptionEnabled', 'ContentMarkingHeaderEnabled', diff --git a/src/pages/security/compliance/sit-templates/index.js b/src/pages/security/compliance/sit-templates/index.js index 3b6bf4b87121..09026a2389ef 100644 --- a/src/pages/security/compliance/sit-templates/index.js +++ b/src/pages/security/compliance/sit-templates/index.js @@ -5,6 +5,7 @@ import { GitHub } from "@mui/icons-material"; import { ApiGetCall } from "../../../../api/ApiCall"; import { CippPolicyImportDrawer } from "../../../../components/CippComponents/CippPolicyImportDrawer.jsx"; import { CippDeployCompliancePolicyDrawer } from "../../../../components/CippComponents/CippDeployCompliancePolicyDrawer.jsx"; +import { CippSitTemplateDetails } from "../../../../components/CippComponents/CippSitTemplateDetails.jsx"; import { PermissionButton } from "../../../../utils/permissions.js"; const Page = () => { @@ -71,11 +72,13 @@ const Page = () => { ]; const offCanvas = { - extendedInfoFields: ["name", "comments", "Pattern", "Confidence", "Locale", "GUID"], + extendedInfoFields: ["name", "comments", "Description", "GUID"], actions: actions, + children: (row) => , + size: "lg", }; - const simpleColumns = ["name", "comments", "Pattern", "Confidence", "Locale", "GUID"]; + const simpleColumns = ["name", "comments", "Description", "GUID"]; return ( { const cardButtonPermissions = ['Security.SensitiveInfoType.ReadWrite'] const actions = [ + { + label: 'Create template based on SIT', + type: 'POST', + icon: , + url: '/api/AddSensitiveInfoTypeTemplate', + data: { + Identity: 'Name', + }, + hideBulk: true, + confirmText: 'Are you sure you want to create a template based on this Sensitive Information Type?', + // Only Microsoft built-ins can't be templated; custom regex and fingerprint SITs both can. + condition: (row) => row.Publisher && !String(row.Publisher).startsWith('Microsoft'), + }, { label: 'Delete SIT', type: 'POST', @@ -31,18 +46,21 @@ const Page = () => { 'Name', 'Description', 'Publisher', + 'Type', 'Recommended', 'RulePackId', 'RulePackVersion', 'State', - 'Type', ], actions: actions, + children: (row) => , + size: 'lg', } const simpleColumns = [ 'Name', 'Publisher', + 'Type', 'Description', 'Recommended', 'RulePackVersion', diff --git a/src/pages/security/defender/defender-cve-exceptions/index.js b/src/pages/security/defender/defender-cve-exceptions/index.js new file mode 100644 index 000000000000..f3f3385ddcff --- /dev/null +++ b/src/pages/security/defender/defender-cve-exceptions/index.js @@ -0,0 +1,204 @@ +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' +import { useSettings } from '../../../../hooks/use-settings.js' +import { Edit, Delete, Sync, Info, CloudDone, Bolt } from '@mui/icons-material' +import { Button, SvgIcon, IconButton, Tooltip, Chip } from '@mui/material' +import { Stack } from '@mui/system' +import { useDialog } from '../../../../hooks/use-dialog' +import { CippApiDialog } from '../../../../components/CippComponents/CippApiDialog' +import { CippQueueTracker } from '../../../../components/CippTable/CippQueueTracker' +import { useState, useEffect } from 'react' +import { CippOffCanvas } from "../../../../components/CippComponents/CippOffCanvas.jsx" + +const Page = () => { + const pageTitle = 'CVE Management' + const cardButtonPermissions = ['Endpoint.MEM.ReadWrite'] + const tenant = useSettings().currentTenant + const isAllTenants = tenant === 'AllTenants' + const syncDialog = useDialog() + const [syncQueueId, setSyncQueueId] = useState(null) + const [useReportDB, setUseReportDB] = useState(isAllTenants) + + // Reset toggle whenever the tenant changes + useEffect(() => { + setUseReportDB(tenant === 'AllTenants') + }, [tenant]) + + const actions = [ + { + label: "Add Exception", + type: "POST", + url: "/api/ExecAddCippCveException", + icon: , + color: "success", + data: { cveId: "cveId" }, + fields: [ + { + label: "Exception Type", + name: "exceptionType", + type: "select", + options: [ + { label: "Risk Accepted", value: "RiskAccepted" }, + { label: "Compensating Control", value: "CompensatingControl" }, + { label: "False Positive", value: "FalsePositive" }, + { label: "Planned Remediation", value: "PlannedRemediation" }, + ], + required: true, + validators: { required: { value: true, message: "Exception type is required" } }, + }, + { + label: "Apply Exception To", + name: "applyTo", + type: "select", + options: [ + { label: "Current Tenant Only", value: "CurrentTenant" }, + { label: "All Affected Tenants", value: "AllAffected" }, + { label: "All Tenants (Global)", value: "Global" }, + ], + required: true, + validators: { required: { value: true, message: "Scope is required" } }, + }, + { + label: "Justification", + name: "justification", + type: "textField", + multiline: true, + rows: 4, + required: true, + validators: { required: { value: true, message: "Justification is required" } }, + }, + { + label: "Exception Expiry Date (Optional)", + name: "expiryDate", + type: "datePicker", + required: false, + }, + ], + confirmText: "Are you sure you want to add an exception for [cveId]?", + multiPost: false, + }, + { + label: "Remove Exception", + type: "POST", + url: "/api/ExecRemoveCippCveException", + icon: , + color: "warning", + data: { cveId: "cveId" }, + fields: [ + { + label: "Remove Exception From", + name: "removeScope", + type: "select", + options: [ + { label: "Current Tenant Only", value: "CurrentTenant" }, + { label: "All Affected Tenants", value: "AllAffected" }, + { label: "All Tenants (Global)", value: "Global" }, + ], + required: true, + validators: { required: { value: true, message: "Scope is required" } }, + }, + ], + confirmText: "Are you sure you want to remove the exception for [cveId]?", + multiPost: false, + condition: (row) => row.hasException === true, + }, + ]; + + const simpleColumns = [ + ...(useReportDB ? ['Tenant', 'CacheTimestamp'] : []), + "cveId", + "tenantCount", + "deviceCount", + "vulnerabilitySeverityLevel", + "exploitabilityLevel", + "hasException", + "affectedDevices", + "affectedTenants" + ] + + const pageActions = [ + + {useReportDB && ( + <> + + + + )} + + + : } + 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/incidents/list-incidents/index.js b/src/pages/security/incidents/list-incidents/index.js index f2070f55a915..57c0ba796b33 100644 --- a/src/pages/security/incidents/list-incidents/index.js +++ b/src/pages/security/incidents/list-incidents/index.js @@ -1,7 +1,7 @@ -import { useState } from "react"; -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; -import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; -import { PersonAdd, PlayArrow, Assignment, Done } from "@mui/icons-material"; +import { useState } from 'react' +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' +import { PersonAdd, PlayArrow, Assignment, Done, Warning } from '@mui/icons-material' import { Button, Accordion, @@ -11,67 +11,67 @@ import { SvgIcon, Stack, Box, -} from "@mui/material"; -import { Grid } from "@mui/system"; -import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; -import { useForm } from "react-hook-form"; -import CippFormComponent from "../../../../components/CippComponents/CippFormComponent"; -import { FunnelIcon, XMarkIcon } from "@heroicons/react/24/outline"; -import { useSettings } from "../../../../hooks/use-settings"; +} from '@mui/material' +import { Grid } from '@mui/system' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' +import { useForm } from 'react-hook-form' +import CippFormComponent from '../../../../components/CippComponents/CippFormComponent' +import { FunnelIcon, XMarkIcon } from '@heroicons/react/24/outline' +import { useSettings } from '../../../../hooks/use-settings' const defaultStartDate = (() => { - 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/deleted-sites.js b/src/pages/teams-share/deleted-sites.js new file mode 100644 index 000000000000..59a8403d4333 --- /dev/null +++ b/src/pages/teams-share/deleted-sites.js @@ -0,0 +1,46 @@ +import { Layout as DashboardLayout } from '../../layouts/index.js' +import { usePermissions } from '../../hooks/use-permissions' +import { CippTablePage } from '../../components/CippComponents/CippTablePage.jsx' +import { RestoreFromTrash } from '@mui/icons-material' + +const Page = () => { + const { checkPermissions } = usePermissions() + const canWriteSite = checkPermissions(['Sharepoint.Site.ReadWrite']) + + const actions = [ + { + label: 'Restore Site', + type: 'POST', + icon: , + url: '/api/ExecRestoreDeletedSite', + data: { + SiteUrl: 'Url', + }, + confirmText: + 'Restore [Url] from the tenant recycle bin? Large sites can take a while to finish restoring.', + condition: () => canWriteSite, + multiPost: false, + }, + ] + + return ( + + ) +} + +Page.getLayout = (page) => {page} + +export default Page diff --git a/src/pages/teams-share/external-users.js b/src/pages/teams-share/external-users.js new file mode 100644 index 000000000000..85fd7a8fa31c --- /dev/null +++ b/src/pages/teams-share/external-users.js @@ -0,0 +1,83 @@ +import { Layout as DashboardLayout } from '../../layouts/index.js' +import { usePermissions } from '../../hooks/use-permissions' +import { useSettings } from '../../hooks/use-settings' +import { CippTablePage } from '../../components/CippComponents/CippTablePage.jsx' +import { NoAccounts } from '@mui/icons-material' + +/* + * Lists SharePoint Online's tenant external users store (live) and classifies every entry: + * - Entra B2B: backed by a live Entra guest object + * - Orphaned B2B: the Entra guest was deleted but SharePoint still knows the user + * - SharePoint-only: legacy email-authenticated guest that never had an Entra object + */ +const Page = () => { + const tenantFilter = useSettings().currentTenant + const { checkPermissions } = usePermissions() + const canWriteSite = checkPermissions(['Sharepoint.Site.ReadWrite']) + + const actions = [ + { + label: 'Remove Guest Access', + type: 'POST', + icon: , + url: '/api/ExecRemoveSPOExternalUser', + customDataformatter: (row) => { + const r = Array.isArray(row) ? row[0] : row + return { + tenantFilter: r.Tenant ?? tenantFilter, + EntraUserId: r.EntraUserId, + LoginName: r.LoginName, + SiteUrls: Array.isArray(r.Sites) ? r.Sites : [], + DisplayName: r.DisplayName, + } + }, + confirmText: + 'Fully remove guest access for [DisplayName]? This deletes their Entra guest account (if one exists) AND removes them from every site listed in the Sites column, so nothing is left orphaned. Sharing links they hold can be revoked from the Sharing Report; the inert SharePoint store entry ages out on its own.', + color: 'error', + condition: (row) => canWriteSite && (row.InEntra || (row.Sites ?? []).length > 0), + multiPost: false, + }, + ] + + const filters = [ + { + filterName: 'SharePoint-only', + value: [{ id: 'GuestType', value: 'SharePoint-only (email authenticated)' }], + type: 'column', + }, + { + filterName: 'Orphaned B2B', + value: [{ id: 'GuestType', value: 'Orphaned B2B (not in Entra)' }], + type: 'column', + }, + { + filterName: 'Entra B2B', + value: [{ id: 'GuestType', value: 'Entra B2B' }], + type: 'column', + }, + ] + + return ( + + ) +} + +Page.getLayout = (page) => {page} + +export default Page diff --git a/src/pages/teams-share/sharepoint/index.js b/src/pages/teams-share/sharepoint/index.js index 2c8ae199ee97..99c9ab46c157 100644 --- a/src/pages/teams-share/sharepoint/index.js +++ b/src/pages/teams-share/sharepoint/index.js @@ -11,16 +11,22 @@ import { Delete, CleaningServices, Assessment, + FolderShared, + RestoreFromTrash, + Settings, } from '@mui/icons-material' import Link from 'next/link' import { Stack } from '@mui/system' import { CippDataTable } from '../../../components/CippTable/CippDataTable' import { useSettings } from '../../../hooks/use-settings' +import { usePermissions } from '../../../hooks/use-permissions' 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' +import { CippEditSitePropertiesForm } from '../../../components/CippComponents/CippEditSitePropertiesForm' +import { CippSiteRecycleBinDialog } from '../../../components/CippComponents/CippSiteRecycleBinDialog' // Friendly labels for the SharePoint version cleanup (trim) job progress fields. const VERSION_CLEANUP_LABELS = { @@ -68,7 +74,7 @@ const VersionCleanupStatusBody = ({ statusApi }) => { } const propertyItems = VERSION_CLEANUP_FIELDS.filter( - (key) => progress?.[key] !== undefined && progress?.[key] !== '', + (key) => progress?.[key] !== undefined && progress?.[key] !== '' ).map((key) => ({ label: VERSION_CLEANUP_LABELS[key], value: String(progress[key]), @@ -103,12 +109,7 @@ const VersionCleanupStatusModal = ({ row, tenantFilter, drawerVisible, setDrawer }) return ( - setDrawerVisible(false)} - > + setDrawerVisible(false)}> Cleanup Job Status{siteRow?.displayName ? ` โ€” ${siteRow.displayName}` : ''} @@ -127,6 +128,13 @@ const VersionCleanupStatusModal = ({ row, tenantFilter, drawerVisible, setDrawer const Page = () => { const pageTitle = 'SharePoint Sites' const tenantFilter = useSettings().currentTenant + const { checkPermissions } = usePermissions() + const canWriteSite = checkPermissions(['Sharepoint.Site.ReadWrite']) + const canReadSite = checkPermissions(['Sharepoint.Site.Read', 'Sharepoint.Site.ReadWrite']) + const canReadRecycleBin = checkPermissions([ + 'Sharepoint.SiteRecycleBin.Read', + 'Sharepoint.SiteRecycleBin.ReadWrite', + ]) const reportDB = useCippReportDB({ apiUrl: '/api/ListSites?type=SharePointSiteUsage', queryKey: 'ListSites-SharePointSiteUsage', @@ -150,7 +158,8 @@ const Page = () => { URL: 'webUrl', SharePointType: 'rootWebTemplate', }, - confirmText: 'Select the User to add as a member.', + confirmText: 'Select the User to add and the site role to add them to.', + condition: () => canWriteSite, fields: [ { type: 'autoComplete', @@ -176,7 +185,21 @@ const Page = () => { showRefresh: true, }, }, + { + type: 'radio', + name: 'Role', + label: 'Site Role', + options: [ + { label: 'Members', value: 'Members' }, + { label: 'Owners', value: 'Owners' }, + { label: 'Visitors', value: 'Visitors' }, + ], + }, ], + defaultvalues: { + Role: 'Members', + }, + allowResubmit: true, multiPost: false, }, { @@ -187,38 +210,195 @@ const Page = () => { data: { groupId: 'ownerPrincipalName', add: false, - URL: 'URL', + URL: 'webUrl', SharePointType: 'rootWebTemplate', }, - confirmText: 'Select the User to remove as a member.', + confirmText: 'Select the user to remove from their site role.', + condition: () => canWriteSite, + children: ({ formHook, row }) => { + const siteRow = Array.isArray(row) ? row[0] : row + return ( + + `${member.Title} (${member.UserPrincipalName}) โ€” ${member.Group}`, + valueField: 'UserPrincipalName', + addedField: { + Group: 'Group', + Type: 'Type', + }, + dataFilter: (options) => + options.filter( + (option, index, all) => + option.value && + ['Owners', 'Members', 'Visitors'].includes(option.addedFields?.Group) && + all.findIndex( + (o) => + o.value === option.value && + o.addedFields?.Group === option.addedFields?.Group + ) === index + ), + showRefresh: true, + }} + /> + ) + }, + multiPost: false, + allowResubmit: true, + }, + { + label: 'Remove User From Site', + type: 'POST', + icon: , + url: '/api/ExecRemoveSiteUser', + data: { + SiteUrl: 'webUrl', + }, + confirmText: + 'Remove a user from the entire site: this removes them from every site group and direct permission grant at once. Sharing links they received are not revoked.', + condition: () => canWriteSite, + children: ({ formHook, row }) => { + const siteRow = Array.isArray(row) ? row[0] : row + return ( + + `${member.Title} (${member.UserPrincipalName})${member.IsGuest ? ' โ€” Guest' : ''} โ€” ${member.Group}`, + valueField: 'UserPrincipalName', + addedField: { + LoginName: 'LoginName', + Type: 'Type', + }, + dataFilter: (options) => + options.filter( + (option, index, all) => + option.value && + option.addedFields?.Type === 'User' && + all.findIndex((o) => o.value === option.value) === index + ), + showRefresh: true, + }} + /> + ) + }, + multiPost: false, + }, + { + label: 'Revoke Sharing Links', + type: 'POST', + icon: , + url: '/api/ExecBulkRemoveSharingLinks', + data: { + SiteUrl: 'webUrl', + }, + confirmText: + 'Bulk revoke sharing links on [displayName]. This uses the sharing report cache: links created since the last sharing sync are not covered - run a sync from the Sharing Report page first for full coverage.', + condition: () => canWriteSite, fields: [ { - type: 'autoComplete', - name: 'user', - label: 'Select User', - multiple: false, - creatable: false, - api: { - url: '/api/ListGraphRequest', - data: { - Endpoint: 'users', - $select: 'id,displayName,userPrincipalName', - $top: 999, - $count: true, - }, - queryKey: 'ListUsersAutoComplete', - dataKey: 'Results', - labelField: (user) => `${user.displayName} (${user.userPrincipalName})`, - valueField: 'userPrincipalName', - addedField: { - id: 'id', - }, - showRefresh: true, - }, + type: 'radio', + name: 'Scope', + label: 'Which links to revoke', + options: [ + { label: 'Anonymous links only (anyone with the link)', value: 'Anonymous' }, + { label: 'Anonymous + external user shares', value: 'External' }, + { label: 'All sharing links, including internal', value: 'All' }, + ], }, ], + defaultvalues: { + Scope: 'Anonymous', + }, multiPost: false, }, + { + label: 'Edit Site', + type: 'POST', + icon: , + url: '/api/ExecSetSiteProperties', + confirmText: + 'Edit site properties for [displayName]. Fields are prefilled with the current values.', + condition: () => canWriteSite, + children: ({ formHook, row }) => ( + + ), + customDataformatter: (row, action, formData) => { + const siteRow = Array.isArray(row) ? row[0] : row + const isGroupSite = siteRow?.rootWebTemplate === 'Group' + const v = (x) => (x && typeof x === 'object' && 'value' in x ? x.value : x) + const payload = { + tenantFilter: siteRow.Tenant ?? tenantFilter, + SiteUrl: siteRow.webUrl, + SharingCapability: v(formData.SharingCapability), + DefaultSharingLinkType: v(formData.DefaultSharingLinkType), + DefaultLinkPermission: v(formData.DefaultLinkPermission), + LockState: v(formData.LockState), + } + if (!isGroupSite) { + payload.Title = formData.Title + payload.SharingDomainRestrictionMode = v(formData.SharingDomainRestrictionMode) + payload.OverrideTenantAnonymousLinkExpirationPolicy = + !!formData.OverrideTenantAnonymousLinkExpirationPolicy + payload.InheritVersionPolicyFromTenant = !!formData.InheritVersionPolicyFromTenant + } + if (!isGroupSite && v(formData.SharingDomainRestrictionMode) === 'AllowList') { + payload.SharingAllowedDomainList = formData.SharingAllowedDomainList + } + if (!isGroupSite && v(formData.SharingDomainRestrictionMode) === 'BlockList') { + payload.SharingBlockedDomainList = formData.SharingBlockedDomainList + } + if (!isGroupSite && formData.OverrideTenantAnonymousLinkExpirationPolicy) { + payload.AnonymousLinkExpirationInDays = parseInt( + formData.AnonymousLinkExpirationInDays ?? 0, + 10 + ) + } + const storageMax = parseInt(formData.StorageMaximumLevel, 10) + const storageWarn = parseInt(formData.StorageWarningLevel, 10) + if (!isNaN(storageMax) && storageMax > 0) payload.StorageMaximumLevel = storageMax + if (!isNaN(storageWarn) && storageWarn > 0) payload.StorageWarningLevel = storageWarn + if (!isGroupSite && !formData.InheritVersionPolicyFromTenant) { + payload.EnableAutoExpirationVersionTrim = !!formData.EnableAutoExpirationVersionTrim + if (!formData.EnableAutoExpirationVersionTrim) { + payload.MajorVersionLimit = parseInt(formData.MajorVersionLimit ?? 0, 10) + payload.ExpireVersionsAfterDays = parseInt(formData.ExpireVersionsAfterDays ?? 0, 10) + } + } + return payload + }, + multiPost: false, + allowResubmit: true, + }, { label: 'Add Site Admin', type: 'POST', @@ -230,6 +410,7 @@ const Page = () => { URL: 'webUrl', }, confirmText: 'Select the User to add to the Site Admins permissions', + condition: () => canWriteSite, fields: [ { type: 'autoComplete', @@ -269,6 +450,7 @@ const Page = () => { URL: 'webUrl', }, confirmText: 'Select the User to remove from the Site Admins permissions', + condition: () => canWriteSite, fields: [ { type: 'autoComplete', @@ -297,6 +479,125 @@ const Page = () => { ], multiPost: false, }, + { + label: 'Set Library Permission', + type: 'POST', + icon: , + url: '/api/ExecSetLibraryPermission', + confirmText: + 'Grant users or groups a permission level on a document library of [displayName].', + condition: () => canWriteSite, + children: ({ formHook, row }) => { + const siteRow = Array.isArray(row) ? row[0] : row + return ( + <> + library.Title, + valueField: 'Id', + showRefresh: true, + }} + /> + `${user.displayName} (${user.userPrincipalName})`, + valueField: 'userPrincipalName', + addedField: { + id: 'id', + }, + showRefresh: true, + }} + /> + + group.mail ? `${group.displayName} (${group.mail})` : group.displayName, + valueField: 'id', + addedField: { + securityEnabled: 'securityEnabled', + groupTypes: 'groupTypes', + }, + showRefresh: true, + }} + /> + + + ) + }, + defaultvalues: { + PermissionLevel: 'read', + }, + customDataformatter: (row, action, formData) => { + const siteRow = Array.isArray(row) ? row[0] : row + return { + tenantFilter: siteRow.Tenant ?? tenantFilter, + SiteUrl: siteRow.webUrl, + ListId: formData.library?.value, + LibraryName: formData.library?.label, + PermissionLevel: formData.PermissionLevel, + Users: formData.users ?? [], + Groups: formData.groups ?? [], + } + }, + multiPost: false, + }, { label: 'Delete Site', type: 'POST', @@ -306,8 +607,24 @@ const Page = () => { SiteId: 'siteId', }, confirmText: - 'Are you sure you want to delete this SharePoint site? This action cannot be undone.', + 'Are you sure you want to delete this SharePoint site? Deleted sites can be restored from the Deleted Sites page for 93 days.', color: 'error', + // System sites cannot be deleted (SPO rejects it or the tenant breaks): admin site, + // My Site host, search/compliance centers, root site, content type hub. Team channel + // sites are deleted by deleting the channel in Teams, not directly. + condition: (row) => + canWriteSite && + ![ + 'Tenant Admin Site', + 'My Site Host', + 'Basic Search Center', + 'Compliance Policy Center', + 'SharePoint Online Tenant Fundamental Site', + 'Team Channel', + 'App Catalog Site', + ].includes(row.rootWebTemplate) && + !/\.sharepoint\.com\/?$/i.test(row.webUrl ?? '') && + !/\/sites\/contentTypeHub$/i.test(row.webUrl ?? ''), multiPost: false, }, { @@ -320,6 +637,7 @@ const Page = () => { }, confirmText: 'Start a file version cleanup job for [displayName]. This will trim old file versions based on the selected mode.', + condition: () => canWriteSite, children: ({ formHook }) => ( <> { }, multiPost: false, }, + { + label: 'Recycle Bin', + icon: , + condition: () => canReadRecycleBin, + customComponent: (row, { drawerVisible, setDrawerVisible }) => ( + + ), + multiPost: false, + }, { label: 'Check Cleanup Job Status', icon: , + condition: () => canReadSite, customComponent: (row, { drawerVisible, setDrawerVisible }) => ( { url: '/api/ListSiteMembers', data: { SiteId: row.siteId, + SiteUrl: row.webUrl, tenantFilter: tenantFilter, }, dataKey: 'Results', }} - simpleColumns={['fields.Title', 'fields.EMail', 'fields.IsSiteAdmin']} + simpleColumns={['Title', 'Email', 'Group', 'Type', 'IsGuest', 'IsSiteAdmin']} /> ), size: 'lg', // Make the offcanvas extra large diff --git a/src/pages/teams-share/sharing-report/index.js b/src/pages/teams-share/sharing-report/index.js new file mode 100644 index 000000000000..1a6da5aab857 --- /dev/null +++ b/src/pages/teams-share/sharing-report/index.js @@ -0,0 +1,447 @@ +import { Layout as DashboardLayout } from '../../../layouts/index.js' +import { CippInfoBar } from '../../../components/CippCards/CippInfoBar' +import { CippChartCard } from '../../../components/CippCards/CippChartCard' +import { CippImageCard } from '../../../components/CippCards/CippImageCard' +import { CippDataTable } from '../../../components/CippTable/CippDataTable' +import { CippApiDialog } from '../../../components/CippComponents/CippApiDialog' +import { useDialog } from '../../../hooks/use-dialog' +import { ApiGetCall } from '../../../api/ApiCall' +import { useSettings } from '../../../hooks/use-settings' +import { + Alert, + Button, + Chip, + Container, + Divider, + Link, + Stack, + SvgIcon, + Typography, +} from '@mui/material' +import { Grid } from '@mui/system' +import { + ArrowPathIcon, + BuildingOfficeIcon, + ChatBubbleLeftRightIcon, + CloudIcon, + DocumentTextIcon, + GlobeAltIcon, + LinkIcon, + LinkSlashIcon, + ShieldCheckIcon, + UserPlusIcon, +} from '@heroicons/react/24/outline' + +const classificationChipColor = (classification) => { + switch (String(classification).toLowerCase()) { + case 'anonymous': + return 'error' + case 'external': + return 'warning' + case 'internal': + return 'success' + default: + return 'default' + } +} + +// Row detail shown when a sharing link row is clicked. +const SharingLinkDetail = ({ row }) => { + if (!row) return null + + const properties = [ + { label: 'Site', value: row.siteName }, + { label: 'Library', value: row.driveName }, + { label: 'Workload', value: row.workload }, + { label: 'Type', value: row.itemType }, + { label: 'Link Scope', value: row.linkScope }, + { label: 'Link Type', value: row.linkType }, + { label: 'Permission', value: Array.isArray(row.roles) ? row.roles.join(', ') : row.roles }, + { label: 'Password Protected', value: row.hasPassword ? 'Yes' : 'No' }, + { + label: 'Expires', + value: row.expirationDateTime ? new Date(row.expirationDateTime).toLocaleString() : 'Never', + }, + { + label: 'Last Modified', + value: row.lastModifiedDateTime ? new Date(row.lastModifiedDateTime).toLocaleString() : '', + }, + ] + + return ( + + + {row.fileName} + + + + + + {properties + .filter((prop) => prop.value !== undefined && prop.value !== null && prop.value !== '') + .map((prop) => ( + + + {prop.label} + + {String(prop.value)} + + ))} + + {Array.isArray(row.sharedWith) && row.sharedWith.length > 0 && ( + <> + + + Shared With + + + {row.sharedWith.map((recipient) => ( + + ))} + + + )} + + + {row.itemUrl && ( + + File:{' '} + + {row.itemUrl} + + + )} + {row.linkUrl && ( + + Sharing link:{' '} + + {row.linkUrl} + + + )} + + + ) +} + +// Datasets in the CIPP reporting database this report is compiled from. +const syncRows = [ + { Name: 'SharePointSharingLinks' }, + { Name: 'SharePointSiteUsage' }, + { Name: 'OneDriveUsage' }, +] + +const Page = () => { + const currentTenant = useSettings().currentTenant + const syncDialog = useDialog() + const queryKey = `ListSharePointSharing-${currentTenant}` + + const sharing = ApiGetCall({ + url: '/api/ListSharePointSharing', + data: { tenantFilter: currentTenant }, + queryKey: queryKey, + waiting: !!currentTenant && currentTenant !== 'AllTenants', + }) + + const data = sharing.data ?? {} + const summary = data.summary ?? {} + const byScope = data.byScope ?? [] + const byLinkType = data.byLinkType ?? [] + const topSites = data.topSites ?? [] + const needsSync = sharing.isSuccess && !summary.linksSynced + const showLinkCharts = sharing.isFetching || byScope.length > 0 + + const linkActions = [ + { + label: 'Revoke Sharing Link', + type: 'POST', + url: '/api/ExecRemoveSharingLink', + icon: ( + + + + ), + data: { + DriveId: 'driveId', + ItemId: 'itemId', + PermissionId: 'permissionId', + FileName: 'fileName', + CacheId: 'id', + }, + confirmText: + 'Revoke this sharing link for [fileName]? Anyone using this link will lose access to the item.', + color: 'error', + relatedQueryKeys: [queryKey], + multiPost: false, + }, + { + label: 'Open File', + link: '[itemUrl]', + external: true, + target: '_blank', + icon: ( + + + + ), + multiPost: false, + }, + ] + + const linkFilters = [ + { + filterName: 'Anonymous', + value: [{ id: 'classification', value: 'Anonymous' }], + type: 'column', + }, + { + filterName: 'External', + value: [{ id: 'classification', value: 'External' }], + type: 'column', + }, + { + filterName: 'Internal', + value: [{ id: 'classification', value: 'Internal' }], + type: 'column', + }, + { + filterName: 'SharePoint', + value: [{ id: 'workload', value: 'SharePoint' }], + type: 'column', + }, + { + filterName: 'OneDrive', + value: [{ id: 'workload', value: 'OneDrive' }], + type: 'column', + }, + ] + + const linkDetailOffCanvas = { + size: 'lg', + children: (row) => , + } + + return ( + + + {currentTenant === 'AllTenants' ? ( + + + + ) : ( + <> + + + + {summary.lastDataRefresh + ? `Last data refresh: ${new Date(summary.lastDataRefresh).toLocaleString()}` + : ''} + + + + {needsSync && ( + + No cached sharing data found for this tenant yet. Click "Sync data" to scan the + tenant's SharePoint sites and OneDrive accounts for sharing links; the report + populates once the sync completes. + + )} + + + , + name: 'Sharing Links', + data: `${summary.totalLinks ?? 0}`, + }, + { + icon: , + name: 'Anonymous Links', + data: `${summary.anonymousLinks ?? 0}`, + color: 'error', + }, + { + icon: , + name: 'External Links & Shares', + data: `${summary.externalLinks ?? 0}`, + color: 'warning', + }, + { + icon: , + name: 'Internal Links', + data: `${summary.internalLinks ?? 0}`, + color: 'success', + }, + ]} + /> + + + , + name: 'SharePoint Sites', + data: `${summary.sharePointSites ?? 0}`, + }, + { + icon: , + name: 'Teams-Connected Sites', + data: `${summary.teamsSites ?? 0}`, + }, + { + icon: , + name: 'OneDrive Accounts', + data: `${summary.oneDriveAccounts ?? 0}`, + }, + { + icon: , + name: 'Shared Items', + data: `${summary.itemsShared ?? 0}`, + }, + ]} + /> + + + {showLinkCharts && ( + <> + + item.scope)} + chartSeries={byScope.map((item) => item.links)} + totalLabel="Links" + /> + + + item.site)} + chartSeries={topSites.map((item) => item.links)} + totalLabel="Links" + /> + + + item.type)} + chartSeries={byLinkType.map((item) => item.links)} + totalLabel="Links" + /> + + + )} + + {(sharing.isFetching || summary.usageSynced) && ( + <> + + + + + + + + )} + + + + + + + + )} + + + ) +} + +Page.getLayout = (page) => {page} + +export default Page 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} /> + + + + + + + { ? `applications(appId='${applicationClientId}')` : 'applications', tenantFilter: router.query.tenantFilter ?? userSettingsDefaults.currentTenant, + // Always fetch live data on this management page so credential/URI/audience edits reflect immediately. + SkipCache: true, }, queryKey: `Application-appId-${applicationClientId}`, waiting: waiting, @@ -271,6 +273,8 @@ const Page = () => { tenantFilter={tenantForApi} canRemove={canWriteApplication} onRemoved={() => appRequest.refetch()} + canAdd={canWriteApplication} + onAdded={() => appRequest.refetch()} /> ), }, diff --git a/src/pages/tenant/administration/audit-logs/coverage.js b/src/pages/tenant/administration/audit-logs/coverage.js new file mode 100644 index 000000000000..19f3584c53ab --- /dev/null +++ b/src/pages/tenant/administration/audit-logs/coverage.js @@ -0,0 +1,486 @@ +import { useMemo, useState } from "react"; +import { + Button, + Card, + CardContent, + CardHeader, + Chip, + Divider, + Skeleton, + Stack, + Typography, +} from "@mui/material"; +import { useTheme } from "@mui/material/styles"; +import { Box, Container, Grid } from "@mui/system"; +import { + ShieldCheckIcon, + ClockIcon, + ExclamationTriangleIcon, + BoltIcon, +} from "@heroicons/react/24/outline"; +import { Layout as DashboardLayout } from "../../../../layouts/index.js"; +import { TabbedLayout } from "../../../../layouts/TabbedLayout"; +import { CippHead } from "../../../../components/CippComponents/CippHead"; +import { CippDateRangeFilter } from "../../../../components/CippComponents/CippDateRangeFilter"; +import { CippDataTable } from "../../../../components/CippTable/CippDataTable"; +import { CippInfoBar } from "../../../../components/CippCards/CippInfoBar"; +import { CippCoverageHeatmap } from "../../../../components/CippComponents/CippCoverageHeatmap"; +import { Chart } from "../../../../components/chart"; +import CippJsonView from "../../../../components/CippFormPages/CippJSONView"; +import tabOptions from "./tabOptions.json"; +import { useSettings } from "../../../../hooks/use-settings"; +import { ApiGetCall } from "../../../../api/ApiCall"; +import { + buildBuckets, + bucketIndexOf, + bucketStartMs, + classifyRow, + latencyMinutes, + summarize, + toMs, + STATE_LABELS, +} from "../../../../utils/cipp-coverage"; + +const simpleColumns = [ + "Tenant", + "Type", + "WindowStart", + "WindowEnd", + "State", + "SearchStatus", + "RecordCount", + "MatchedCount", + "Attempts", + "RetryCount", + "ThrottleCount", + "ProcessedUtc", + "NextAttemptUtc", + "LastError", + "LastErrorUtc", + "LastPolledUtc", +]; + +const bucketLabel = (ms) => + 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} > - - + - + + + + + + + ) +} + +Page.getLayout = (page) => {page} + +export default Page diff --git a/src/pages/tenant/reports/list-licenses/index.js b/src/pages/tenant/reports/list-licenses/index.js index 61fcfc5b74b6..2a865e66eb67 100644 --- a/src/pages/tenant/reports/list-licenses/index.js +++ b/src/pages/tenant/reports/list-licenses/index.js @@ -2,10 +2,25 @@ import { Layout as DashboardLayout } from '../../../../layouts/index.js' import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' import { AssignmentInd } from '@mui/icons-material' import CippFormComponent from '../../../../components/CippComponents/CippFormComponent' +import { useRouter } from 'next/router' +import { useMemo } from 'react' const Page = () => { const pageTitle = 'Licences Report' const apiUrl = '/api/ListLicensesReport' + const router = useRouter() + + const urlFilters = useMemo(() => { + if (router.query.filters) { + try { + return JSON.parse(router.query.filters) + } catch (e) { + console.error('Failed to parse filters from URL:', e) + return null + } + } + return null + }, [router.query.filters]) const simpleColumns = [ 'Tenant', @@ -83,6 +98,7 @@ const Page = () => { simpleColumns={simpleColumns} actions={actions} offCanvas={offCanvas} + initialFilters={urlFilters} /> ) } diff --git a/src/pages/tools/custom-tests/add.jsx b/src/pages/tools/custom-tests/add.jsx index 9762f797cb01..1a483dd8e545 100644 --- a/src/pages/tools/custom-tests/add.jsx +++ b/src/pages/tools/custom-tests/add.jsx @@ -192,6 +192,14 @@ const Page = () => { setExpandedCacheType(expandedCacheType === cacheType ? null : cacheType) } + // Results may be an array of items or a single object (e.g. AuthenticationMethodsPolicy). + // Normalize to a single representative sample for the structure preview. + const cacheSampleResults = cacheExplorerApi.data?.Results + const cacheSample = Array.isArray(cacheSampleResults) + ? cacheSampleResults[0] + : cacheSampleResults + const hasCacheSample = cacheSample !== undefined && cacheSample !== null + const testScriptApi = ApiPostCall({ urlFromData: true, onResult: (result) => { @@ -1170,9 +1178,9 @@ $md = $summaryTable + "\n\n---\n\n" + $policyTable Loading sample data... - ) : cacheExplorerApi.data?.Results?.length > 0 ? ( + ) : hasCacheSample ? ( diff --git a/src/utils/cipp-coverage.js b/src/utils/cipp-coverage.js new file mode 100644 index 000000000000..ebe3d054f929 --- /dev/null +++ b/src/utils/cipp-coverage.js @@ -0,0 +1,221 @@ +// Pure helpers for the V2 audit-log coverage views (KPIs, time-series charts, per-tenant heatmap). +// All aggregation is derived client-side from the rows ListAuditLogCoverage returns. + +export const NON_TERMINAL = ["Planned", "Created", "Downloaded"]; + +export const STATE_LABELS = { + Processed: "Processed", + InFlight: "In-flight", + Retrying: "Retrying / delayed", + DeadLetter: "Dead-letter", + Skipped: "Skipped", + Gap: "Gap", +}; + +// Candidate bucket sizes (minutes), smallest first. buildBuckets picks the smallest that keeps +// the column count within `target` so the heatmap/charts stay readable across 1h .. 30d periods. +const BUCKET_CHOICES = [30, 60, 120, 180, 360, 720, 1440]; + +export const toMs = (v) => { + if (!v) return null; + const t = new Date(v).getTime(); + return Number.isNaN(t) ? null : t; +}; + +export function pickBucketMinutes(spanMinutes, target = 40) { + for (const m of BUCKET_CHOICES) { + if (spanMinutes / m <= target) return m; + } + return BUCKET_CHOICES[BUCKET_CHOICES.length - 1]; +} + +export function buildBuckets(rows, target = 40) { + const starts = []; + const ends = []; + for (const r of rows) { + const s = toMs(r.WindowStart); + if (s != null) { + starts.push(s); + ends.push(toMs(r.WindowEnd) ?? s); + } + } + if (!starts.length) return null; + const min = Math.min(...starts); + const max = Math.max(...ends); + const spanMin = Math.max(30, (max - min) / 60000); + const bucketMin = pickBucketMinutes(spanMin, target); + const bucketMs = bucketMin * 60000; + const startMs = Math.floor(min / bucketMs) * bucketMs; + const count = Math.max(1, Math.ceil((max - startMs + 1) / bucketMs)); + return { startMs, bucketMs, bucketMin, count }; +} + +export function bucketIndexOf(ms, b) { + if (ms == null || !b) return -1; + const i = Math.floor((ms - b.startMs) / b.bucketMs); + if (i < 0) return -1; + return Math.min(i, b.count - 1); +} + +export function bucketStartMs(i, b) { + return b.startMs + i * b.bucketMs; +} + +// Single-row health classification (current state, not history - retries that recovered read green). +export function classifyRow(r) { + if (r.State === "DeadLetter") return "DeadLetter"; + if (NON_TERMINAL.includes(r.State) && Number(r.Attempts) > 0) return "Retrying"; + if (NON_TERMINAL.includes(r.State)) return "InFlight"; + if (r.State === "Processed") return "Processed"; + if (r.State === "Skipped") return "Skipped"; + return "Processed"; +} + +// Worst (most severe) classification across a cell's rows. +export function classifyCell(rows) { + if (!rows.length) return "Empty"; + const order = ["DeadLetter", "Retrying", "InFlight", "Processed", "Skipped"]; + const present = new Set(rows.map(classifyRow)); + for (const s of order) { + if (present.has(s)) return s; + } + return "Processed"; +} + +export function latencyMinutes(r) { + const we = toMs(r.WindowEnd); + const c = toMs(r.CreatedUtc); + const d = toMs(r.DownloadedUtc); + const p = toMs(r.ProcessedUtc); + return { + create: we != null && c != null ? (c - we) / 60000 : null, + download: c != null && d != null ? (d - c) / 60000 : null, + process: d != null && p != null ? (p - d) / 60000 : null, + total: we != null && p != null ? (p - we) / 60000 : null, + }; +} + +const median = (arr) => { + if (!arr.length) return null; + const s = [...arr].sort((a, b) => a - b); + const m = Math.floor(s.length / 2); + return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; +}; + +// Per-tenant grid of bucket states. Interior empty buckets (between a tenant's first and last +// window) become "Gap"; auditing-disabled tenants (only Skipped rows) render fully Skipped. +export function tenantGrid(rows, b) { + const byTenant = new Map(); + for (const r of rows) { + const key = r.Tenant || r.TenantId || "?"; + if (!byTenant.has(key)) byTenant.set(key, []); + byTenant.get(key).push(r); + } + const out = []; + for (const [name, tRows] of byTenant) { + const cells = Array.from({ length: b.count }, () => []); + for (const r of tRows) { + const i = bucketIndexOf(toMs(r.WindowStart), b); + if (i >= 0) cells[i].push(r); + } + const onlySkipped = tRows.length > 0 && tRows.every((r) => r.State === "Skipped"); + const occ = cells.map((c) => c.length > 0); + const first = occ.indexOf(true); + const last = occ.lastIndexOf(true); + const states = cells.map((c, i) => { + if (c.length) return classifyCell(c); + if (onlySkipped) return "Skipped"; + if (first !== -1 && i > first && i < last) return "Gap"; + return "Empty"; + }); + out.push({ name, cells, states }); + } + out.sort((a, b) => a.name.localeCompare(b.name)); + return out; +} + +// One column per distinct regular-window start time. Tenants create windows on the same cadence, +// so columns line up across tenants - this lets the heatmap show every window individually rather +// than bucketing several into one cell. +export function windowColumns(rows) { + const set = new Set(); + for (const r of rows) { + if (r.Type === "Reconciliation" || r.Type === "Manual") continue; + const s = toMs(r.WindowStart); + if (s != null) set.add(s); + } + return Array.from(set).sort((a, b) => a - b); +} + +// Per-tenant state for each window column. Missing interior columns (a tenant has windows before +// and after this one but not at it) are gaps; auditing-disabled tenants (only Skipped) render Skipped. +export function tenantWindowGrid(rows, columns) { + const colIndex = new Map(); + columns.forEach((ms, i) => colIndex.set(ms, i)); + const byTenant = new Map(); + for (const r of rows) { + if (r.Type === "Reconciliation" || r.Type === "Manual") continue; + const key = r.Tenant || r.TenantId || "?"; + if (!byTenant.has(key)) byTenant.set(key, []); + byTenant.get(key).push(r); + } + const out = []; + for (const [name, tRows] of byTenant) { + const cells = Array.from({ length: columns.length }, () => []); + for (const r of tRows) { + const i = colIndex.get(toMs(r.WindowStart)); + if (i != null) cells[i].push(r); + } + const onlySkipped = tRows.length > 0 && tRows.every((r) => r.State === "Skipped"); + const occ = cells.map((c) => c.length > 0); + const first = occ.indexOf(true); + const last = occ.lastIndexOf(true); + const states = cells.map((c, i) => { + if (c.length) return classifyCell(c); + if (onlySkipped) return "Skipped"; + if (first !== -1 && i > first && i < last) return "Gap"; + return "Empty"; + }); + out.push({ name, cells, states }); + } + out.sort((a, b) => a.name.localeCompare(b.name)); + return out; +} + +export function summarize(rows) { + const num = (v) => Number(v) || 0; + // Manual ad-hoc searches aren't pipeline coverage windows - exclude from dashboard stats. + rows = (rows || []).filter((r) => r.Type !== "Manual"); + const total = rows.length; + const processed = rows.filter((r) => r.State === "Processed").length; + const deadletter = rows.filter((r) => r.State === "DeadLetter").length; + const skipped = rows.filter((r) => r.State === "Skipped").length; + const retriedWindows = rows.filter((r) => num(r.RetryCount) > 0).length; + const throttleEvents = rows.reduce((a, r) => a + num(r.ThrottleCount), 0); + const totalRecords = rows.reduce((a, r) => a + num(r.RecordCount), 0); + const matched = rows.reduce((a, r) => a + num(r.MatchedCount), 0); + const lat = rows + .filter((r) => r.Type !== "Reconciliation" && r.State === "Processed") + .map((r) => latencyMinutes(r).total) + .filter((v) => v != null && v >= 0); + const recon = rows.filter((r) => r.Type === "Reconciliation"); + let gaps = 0; + const cols = windowColumns(rows); + if (cols.length) { + for (const t of tenantWindowGrid(rows, cols)) gaps += t.states.filter((s) => s === "Gap").length; + } + return { + total, + processed, + deadletter, + skipped, + retriedWindows, + throttleEvents, + totalRecords, + matched, + medianLatency: median(lat), + reconTotal: recon.length, + reconProcessed: recon.filter((r) => r.State === "Processed").length, + gaps, + }; +} diff --git a/src/utils/format-alert-item.js b/src/utils/format-alert-item.js new file mode 100644 index 000000000000..cc274405d50b --- /dev/null +++ b/src/utils/format-alert-item.js @@ -0,0 +1,124 @@ +const NOISE_KEYS = new Set(['tenant', 'tenantid', 'tenantfilter']) + +const isIdKey = (key) => /id$/i.test(key) + +export const humanizeCmdlet = (cmdlet) => { + if (!cmdlet) return 'Alert' + const stripped = cmdlet + .replace(/^Get-?CIPPAlert/i, '') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .trim() + return stripped || cmdlet +} + +export const formatFieldName = (key) => + key.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase()) + +export const formatValue = (value) => { + if (value == null) return '' + if (typeof value === 'boolean') return value ? 'Yes' : 'No' + if (typeof value === 'object') return JSON.stringify(value) + const text = String(value) + // Format ISO dates from their calendar parts in UTC so the displayed day matches the + // stored value and never drifts across timezones. + const isoMatch = text.match(/^(\d{4})-(\d{2})-(\d{2})(?:[T ]\d{2}:\d{2})?/) + if (isoMatch) { + const date = new Date( + Date.UTC(Number(isoMatch[1]), Number(isoMatch[2]) - 1, Number(isoMatch[3])) + ) + if (!Number.isNaN(date.getTime())) { + return date.toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }) + } + } + return text +} + +// ContentPreview is compact JSON truncated to 200 chars, so it's often incomplete (no +// closing brace). Parse strictly when possible, else leniently extract the complete +// "key": value pairs so we still get a readable summary instead of a raw blob. +const parsePreview = (preview) => { + if (typeof preview !== 'string') return null + const text = preview.trim() + if (!text.startsWith('{')) return null + if (text.endsWith('}')) { + try { + return JSON.parse(text) + } catch { + // fall through to lenient extraction + } + } + const obj = {} + const pairRegex = /"([^"]+)"\s*:\s*(?:"([^"]*)"|(-?\d+(?:\.\d+)?)|(true|false|null))/g + let match + while ((match = pairRegex.exec(text)) !== null) { + const [, key, str, num, lit] = match + if (str !== undefined) obj[key] = str + else if (num !== undefined) obj[key] = Number(num) + else obj[key] = lit === 'true' ? true : lit === 'false' ? false : null + } + return Object.keys(obj).length > 0 ? obj : null +} + +const pickTitle = (item) => { + const named = + item.UserPrincipalName ?? + item.userPrincipalName ?? + item.AppName ?? + item.appName ?? + item.DisplayName ?? + item.displayName ?? + item.Message ?? + item.message + if (named) return String(named) + for (const [key, value] of Object.entries(item)) { + if (NOISE_KEYS.has(key.toLowerCase())) continue + const formatted = formatValue(value) + if (formatted) return `${formatFieldName(key)}: ${formatted}` + } + return null +} + +const pickDetail = (item, title) => { + const parts = [] + for (const [key, value] of Object.entries(item)) { + if (NOISE_KEYS.has(key.toLowerCase()) || isIdKey(key)) continue + const formatted = formatValue(value) + if (!formatted || formatted === title) continue + parts.push(`${formatFieldName(key)}: ${formatted}`) + if (parts.length >= 2) break + } + return parts.join(' ยท ') +} + +export const describeAlertItem = (rawItem, contentPreview) => { + let item = rawItem + if (item == null) item = parsePreview(contentPreview) + if (typeof item === 'string' && item.trim()) return { title: item.trim(), detail: '' } + if (item && typeof item === 'object') { + const title = pickTitle(item) + if (title) return { title, detail: pickDetail(item, title) } + } + if (typeof contentPreview === 'string' && contentPreview.trim()) { + return { title: contentPreview.trim(), detail: '' } + } + return { title: 'Alert item', detail: '' } +} + +export const getAlertItemFields = (rawItem, contentPreview) => { + let item = rawItem + if (item == null) item = parsePreview(contentPreview) + if (!item || typeof item !== 'object') return [] + const fields = [] + for (const [key, value] of Object.entries(item)) { + if (NOISE_KEYS.has(key.toLowerCase())) continue + const formatted = formatValue(value) + if (!formatted) continue + fields.push({ label: formatFieldName(key), value: formatted }) + } + return fields +} diff --git a/src/utils/get-cipp-formatting.js b/src/utils/get-cipp-formatting.js index e42846b34db3..c31b0eefc8ff 100644 --- a/src/utils/get-cipp-formatting.js +++ b/src/utils/get-cipp-formatting.js @@ -139,6 +139,54 @@ export const getCippFormatting = (data, cellName, type, canReceive, flatten = tr return } + // Microsoft Entra device trust type (Graph device.trustType) + if (cellName === 'trustType' && typeof data === 'string' && data) { + const trustTypeMap = { + workplace: 'Microsoft Entra registered', + azuread: 'Microsoft Entra joined', + serverad: 'Microsoft Entra hybrid joined', + } + return trustTypeMap[data.toLowerCase()] ?? data + } + + // Microsoft Entra device join type (Intune managedDevice.joinType) + if (cellName === 'joinType' && typeof data === 'string' && data) { + const joinTypeMap = { + azureadregistered: 'Microsoft Entra registered', + azureadjoined: 'Microsoft Entra joined', + hybridazureadjoined: 'Microsoft Entra hybrid joined', + unknown: 'Unknown', + } + return joinTypeMap[data.toLowerCase()] ?? data + } + + // Hex color values (a sensitivity label's custom color, content-marking font colors, ...) render + // as a swatch chip. Matches any column named Color or *Color, guarded on the value shape so + // non-hex data in a matching column falls through untouched. + if (cellNameLower.endsWith('color') && typeof data === 'string' && /^#[0-9A-Fa-f]{6}$/.test(data)) { + return isText ? ( + data + ) : ( + + } + /> + ) + } + //if the cellName starts with portal_, return text, or a link with an icon if (cellName.startsWith('portal_')) { const IconComponent = portalIcons[cellName] @@ -181,6 +229,32 @@ export const getCippFormatting = (data, cellName, type, canReceive, flatten = tr ) } + // Audit-log coverage timestamps: render as an ABSOLUTE date in the browser's local timezone + // (long format) rather than relative "x ago". The UTC ISO values carry a Z so they're + // unambiguous; parseCippDate also handles epoch. Checked before the relative timeAgoArray below. + const absoluteDateArray = [ + 'WindowStart', + 'WindowEnd', + 'CreatedUtc', + 'DownloadedUtc', + 'ProcessedUtc', + 'NextAttemptUtc', + 'LastErrorUtc', + 'LastPolledUtc', + ] + if (absoluteDateArray.includes(cellName)) { + if (data === null || data === undefined || data === '') { + return isText ? '' : '' + } + const dt = parseCippDate(data) + if (isNaN(dt.getTime())) return isText ? '' : '' + if (dt.getTime() === 0) return isText ? '' : 'Never' + // text mode: Date object so MRT sorts chronologically (toLocaleString for CSV export); + // cell mode: long absolute string in the browser's locale + timezone. + if (isText) return canReceive === false ? dt.toLocaleString() : dt + return dt.toLocaleString() + } + const timeAgoArray = [ 'ExecutedTime', 'ScheduledTime', @@ -1037,17 +1111,33 @@ export const getCippFormatting = (data, cellName, type, canReceive, flatten = tr ) } - //if string starts with http, return a link + //if string starts with http, return a link - but only when it parses as a real + //absolute http(s) URL. Defanged URLs (e.g. https[:]//bad.com from Check) fail to + //parse and would otherwise render as a link relative to the CIPP instance, so + //those are shown as plain text with only the copy button. if (typeof data === 'string' && data.toLowerCase().startsWith('http')) { - return isText ? ( - data - ) : ( + let isValidUrl = false + try { + const parsedUrl = new URL(data) + isValidUrl = parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:' + } catch { + isValidUrl = false + } + if (isText) { + return data + } + return isValidUrl ? ( <> URL + ) : ( + <> + {data} + + ) } diff --git a/src/utils/icon-registry.js b/src/utils/icon-registry.js index 77dfe61eb091..6b3b6fd0c047 100644 --- a/src/utils/icon-registry.js +++ b/src/utils/icon-registry.js @@ -31,6 +31,7 @@ import { Lock, Mail, ManageAccounts, + ManageSearch, Notifications, Person, Policy, @@ -78,6 +79,7 @@ export const iconRegistry = { Lock, Mail, ManageAccounts, + ManageSearch, Notifications, Person, Policy, diff --git a/src/utils/skip-recursion-keys.js b/src/utils/skip-recursion-keys.js new file mode 100644 index 000000000000..d6d32d8cbf9d --- /dev/null +++ b/src/utils/skip-recursion-keys.js @@ -0,0 +1,9 @@ +// Keys whose nested objects are intentionally NOT recursed into. +// +// The data table renders these as a single column (via getCippFormatting) rather +// than splitting them into dotted sub-columns. The CSV and PDF exporters must use +// the same list when flattening rows, otherwise a value like `location` is +// flattened to `location.city` / `location.countryOrRegion` while the column id +// stays `location`, so the export looks up `location`, finds nothing, and writes +// "No data" for every row (GitHub issue #6237). +export const SKIP_RECURSION_KEYS = ['location', 'ScheduledBackupValues', 'Tenant'] diff --git a/yarn.lock b/yarn.lock index 7ae67d5020a2..3db3099e91d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1526,57 +1526,57 @@ "@emnapi/runtime" "^1.4.3" "@tybys/wasm-util" "^0.10.0" -"@next/env@16.2.2": - version "16.2.2" - resolved "https://registry.yarnpkg.com/@next/env/-/env-16.2.2.tgz#209b1972833367f1009d07c40250eae9924b5489" - integrity sha512-LqSGz5+xGk9EL/iBDr2yo/CgNQV6cFsNhRR2xhSXYh7B/hb4nePCxlmDvGEKG30NMHDFf0raqSyOZiQrO7BkHQ== +"@next/env@16.2.10": + version "16.2.10" + resolved "https://registry.npmjs.org/@next/env/-/env-16.2.10.tgz#0e9473a5577807292e11d3bf9e075d3bf036860f" + integrity sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA== -"@next/eslint-plugin-next@16.2.3": - version "16.2.3" - resolved "https://registry.yarnpkg.com/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.3.tgz#c8a1c0a529854b3e7c04efeca11a681fe5cae0ed" - integrity sha512-nE/b9mht28XJxjTwKs/yk7w4XTaU3t40UHVAky6cjiijdP/SEy3hGsnQMPxmXPTpC7W4/97okm6fngKnvCqVaA== +"@next/eslint-plugin-next@16.2.10": + version "16.2.10" + resolved "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.10.tgz#6b1ecd39307c48e5fca3275a307b5086cc6eef4c" + integrity sha512-Gs8D2m21VnJeFo9qvYIIqJH94frWerWYu41BprU1pLtRVF7PCQNLiFZZ3fG+iPuj3K83Cwv/rt+msLOy8Qgu3Q== dependencies: fast-glob "3.3.1" -"@next/swc-darwin-arm64@16.2.2": - version "16.2.2" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.2.tgz#3fef301c711e8c249367a8e5bf6de70fb74a05a4" - integrity sha512-B92G3ulrwmkDSEJEp9+XzGLex5wC1knrmCSIylyVeiAtCIfvEJYiN3v5kXPlYt5R4RFlsfO/v++aKV63Acrugg== - -"@next/swc-darwin-x64@16.2.2": - version "16.2.2" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.2.tgz#729584bfae41fca5e381229a3d1fe0eaf313cc0e" - integrity sha512-7ZwSgNKJNQiwW0CKhNm9B1WS2L1Olc4B2XY0hPYCAL3epFnugMhuw5TMWzMilQ3QCZcCHoYm9NGWTHbr5REFxw== - -"@next/swc-linux-arm64-gnu@16.2.2": - version "16.2.2" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.2.tgz#edd526da879fe56e4cb82d57aeb5174956c65493" - integrity sha512-c3m8kBHMziMgo2fICOP/cd/5YlrxDU5YYjAJeQLyFsCqVF8xjOTH/QYG4a2u48CvvZZSj1eHQfBCbyh7kBr30Q== - -"@next/swc-linux-arm64-musl@16.2.2": - version "16.2.2" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.2.tgz#f5990229520663cd759b0eaa426dace2b0510a10" - integrity sha512-VKLuscm0P/mIfzt+SDdn2+8TNNJ7f0qfEkA+az7OqQbjzKdBxAHs0UvuiVoCtbwX+dqMEL9U54b5wQ/aN3dHeg== - -"@next/swc-linux-x64-gnu@16.2.2": - version "16.2.2" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.2.tgz#49b3fcdf73e0403fde8dc9309488c5dd3d19b587" - integrity sha512-kU3OPHJq6sBUjOk7wc5zJ7/lipn8yGldMoAv4z67j6ov6Xo/JvzA7L7LCsyzzsXmgLEhk3Qkpwqaq/1+XpNR3g== - -"@next/swc-linux-x64-musl@16.2.2": - version "16.2.2" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.2.tgz#c7fa2e5cb97876efcc8773ae892e426aec1b6f97" - integrity sha512-CKXRILyErMtUftp+coGcZ38ZwE/Aqq45VMCcRLr2I4OXKrgxIBDXHnBgeX/UMil0S09i2JXaDL3Q+TN8D/cKmg== - -"@next/swc-win32-arm64-msvc@16.2.2": - version "16.2.2" - resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.2.tgz#4d144d95344d684b62710246b15f306b3ee24341" - integrity sha512-sS/jSk5VUoShUqINJFvNjVT7JfR5ORYj/+/ZpOYbbIohv/lQfduWnGAycq2wlknbOql2xOR0DoV0s6Xfcy49+g== - -"@next/swc-win32-x64-msvc@16.2.2": - version "16.2.2" - resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.2.tgz#1120617f423b81a4ba588b289aeafc9c03526b71" - integrity sha512-aHaKceJgdySReT7qeck5oShucxWRiiEuwCGK8HHALe6yZga8uyFpLkPgaRw3kkF04U7ROogL/suYCNt/+CuXGA== +"@next/swc-darwin-arm64@16.2.10": + version "16.2.10" + resolved "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.10.tgz#53ec3c673ddebf626a34dec1a883906e3b5e05f7" + integrity sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA== + +"@next/swc-darwin-x64@16.2.10": + version "16.2.10" + resolved "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.10.tgz#2066e0f017e42555609710fee371d836588ecd14" + integrity sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ== + +"@next/swc-linux-arm64-gnu@16.2.10": + version "16.2.10" + resolved "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.10.tgz#5614f83fd77564b172d26c7a46aaa85f11e7759f" + integrity sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg== + +"@next/swc-linux-arm64-musl@16.2.10": + version "16.2.10" + resolved "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.10.tgz#bdbfd158a2bbf03230bf0517b1e8f8a906315562" + integrity sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A== + +"@next/swc-linux-x64-gnu@16.2.10": + version "16.2.10" + resolved "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.10.tgz#21f386bb298936a7f7a6bea6830b7edb713b52dc" + integrity sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA== + +"@next/swc-linux-x64-musl@16.2.10": + version "16.2.10" + resolved "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.10.tgz#069c43604bf54d46eebb3d25b91d3008d4995397" + integrity sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw== + +"@next/swc-win32-arm64-msvc@16.2.10": + version "16.2.10" + resolved "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.10.tgz#b6b99162b7600f13e5247aa2d1faa469fc8474dd" + integrity sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA== + +"@next/swc-win32-x64-msvc@16.2.10": + version "16.2.10" + resolved "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.10.tgz#9010dac186f3ccff6f1f220e5d5ff7443910e035" + integrity sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A== "@nivo/colors@0.99.0": version "0.99.0" @@ -1912,11 +1912,6 @@ redux-thunk "^3.1.0" reselect "^5.1.0" -"@remirror/core-constants@3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@remirror/core-constants/-/core-constants-3.0.0.tgz#96fdb89d25c62e7b6a5d08caf0ce5114370e3b8f" - integrity sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg== - "@rtsao/scc@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" @@ -2064,25 +2059,27 @@ dependencies: remove-accents "0.5.0" -"@tanstack/query-core@5.100.10": - version "5.100.10" - resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.100.10.tgz#aeb34d301fd4ff9762e67dfa018adc33b7a18be4" - integrity sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w== +"@tanstack/query-core@5.101.2": + version "5.101.2" + resolved "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz#c901fb76b68169ff5e1e44017404e7e2e90ce1af" + integrity sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw== "@tanstack/query-core@5.91.2": version "5.91.2" resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.91.2.tgz#d83825a928aa49ded38d3910f05284178cce89d3" integrity sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw== -"@tanstack/query-core@5.96.2": - version "5.96.2" - resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.96.2.tgz#766dab253476afd0b27959b66abb606d8d2dd9f5" - integrity sha512-hzI6cTVh4KNRk8UtoIBS7Lv9g6BnJPXvBKsvYH1aGWvv0347jT3BnSvztOE+kD76XGvZnRC/t6qdW1CaIfwCeA== +"@tanstack/query-devtools@5.101.2": + version "5.101.2" + resolved "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.101.2.tgz#83257da8d449465352bef50fe4c2956154389145" + integrity sha512-o+wHcqgN7Pp0s8v1i0UGq/ZrrEKrxdIiMQmKRdYb2w7NPtylYSJ4+wg/tIn71m9DLstwUwdEGAvROdly6HXP6w== -"@tanstack/query-devtools@5.100.10": - version "5.100.10" - resolved "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.100.10.tgz#1972789fdc7c4cb9ec2062d51f25bc4dc655a27b" - integrity sha512-3DmJf25hDPus5IpVvp6ujXv6bKV2zPzI9vpbAmpJigsL/H6DPvPjmf7/Q9yVKEke//8fgeQ45abjgnLuyYxAiw== +"@tanstack/query-persist-client-core@5.101.2": + version "5.101.2" + resolved "https://registry.npmjs.org/@tanstack/query-persist-client-core/-/query-persist-client-core-5.101.2.tgz#3bcd02c1ee2090158b4b67c5e4e51cbaa694dc71" + integrity sha512-rgMsbvBVpSPDUsprC9FYxojdG0Q1LH6yq1i3DXz2aps4VEqv2l4Msj3YDqIifU3xkl/DrYe9YWntZAJLrM6xTg== + dependencies: + "@tanstack/query-core" "5.101.2" "@tanstack/query-persist-client-core@5.92.4": version "5.92.4" @@ -2091,13 +2088,6 @@ dependencies: "@tanstack/query-core" "5.91.2" -"@tanstack/query-persist-client-core@5.96.2": - version "5.96.2" - resolved "https://registry.yarnpkg.com/@tanstack/query-persist-client-core/-/query-persist-client-core-5.96.2.tgz#65ea5a2104a85a2f39ef1a007f6f0ad63fbf1c49" - integrity sha512-BYsP8folbvxzZsNnWJxSenEAdepGNfv809150U78D84yt/THi33EwfUCcdKWFbma5XKwlaFQGWMJKeWnVJ6GVA== - dependencies: - "@tanstack/query-core" "5.96.2" - "@tanstack/query-sync-storage-persister@^5.90.25": version "5.90.27" resolved "https://registry.yarnpkg.com/@tanstack/query-sync-storage-persister/-/query-sync-storage-persister-5.90.27.tgz#249c055565e31e0587c2b1900b0d7e0012982dd3" @@ -2106,26 +2096,26 @@ "@tanstack/query-core" "5.91.2" "@tanstack/query-persist-client-core" "5.92.4" -"@tanstack/react-query-devtools@^5.100.10": - version "5.100.10" - resolved "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.100.10.tgz#cca3479cc2c8b434637c31f8119fe6ff93e5832c" - integrity sha512-zes0+o9ef5rAZXJ9f/SeaLs2nufJaeVkZkl/Or9NGrWVF41kL9Od9ED9nCwtQlgiF2VGtrzhEw5AU/igAO+aAg== +"@tanstack/react-query-devtools@^5.101.2": + version "5.101.2" + resolved "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.101.2.tgz#5c6f8b97814242c8ba0c6ffe5680f8751902caae" + integrity sha512-eU7HctdA9gDjqoERoEdzLbw9DiqnBDfh5+Hu0u26gjqoHJezOpQAuiesDL2VvkU+2cPV76zgv0tMZsOrI4LjnQ== dependencies: - "@tanstack/query-devtools" "5.100.10" + "@tanstack/query-devtools" "5.101.2" -"@tanstack/react-query-persist-client@^5.96.2": - version "5.96.2" - resolved "https://registry.yarnpkg.com/@tanstack/react-query-persist-client/-/react-query-persist-client-5.96.2.tgz#b47d62fc990a9fd38ddcf4a080d1300ae887e5a0" - integrity sha512-smQ38oVPlnvkG+G7R60IAD9X6azJLRjHEd7twml9XBLYM31ncPDP0tUKy/Gv/4ItVmKTtjZ5VabXpVZxnaWSww== +"@tanstack/react-query-persist-client@^5.101.2": + version "5.101.2" + resolved "https://registry.npmjs.org/@tanstack/react-query-persist-client/-/react-query-persist-client-5.101.2.tgz#673554fe900861b3075f074965d0f63f3f213be9" + integrity sha512-19UpaRtf0lvB2QAJ2MoixxtGf3osMOd508g99EsttUj4+3eLs+ulnNgYjB7AkQMHrRlcbVXqpVNqWkB4a7g8Zw== dependencies: - "@tanstack/query-persist-client-core" "5.96.2" + "@tanstack/query-persist-client-core" "5.101.2" -"@tanstack/react-query@^5.100.10": - version "5.100.10" - resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.100.10.tgz#3bf1844efd76f5f68f9f39da2917fc4c6023e726" - integrity sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q== +"@tanstack/react-query@^5.101.2": + version "5.101.2" + resolved "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz#d886331bcd29df84bc04c45d93192b8207d17c7f" + integrity sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg== dependencies: - "@tanstack/query-core" "5.100.10" + "@tanstack/query-core" "5.101.2" "@tanstack/react-table@8.20.6": version "8.20.6" @@ -2297,29 +2287,24 @@ resolved "https://registry.yarnpkg.com/@tiptap/extensions/-/extensions-3.20.5.tgz#d2460b110deed4a71aca4c0d37816fc8845b22ad" integrity sha512-c4am6SznqfMnbUNSh4MvufiD7cMLdqL1BArok22uBgSWkS1sB9RVBYe8+x0jrOkk0UPEVlzDHbQ+nU+WmIyS2Q== -"@tiptap/pm@^3.20.5", "@tiptap/pm@^3.22.3": - version "3.22.3" - resolved "https://registry.yarnpkg.com/@tiptap/pm/-/pm-3.22.3.tgz#e911150e900d52b2e5ccd596b32187da46f924d6" - integrity sha512-NjfWjZuvrqmpICT+GZWNIjtOdhPyqFKDMtQy7tsQ5rErM9L2ZQdy/+T/BKSO1JdTeBhdg9OP+0yfsqoYp2aT6A== - dependencies: - prosemirror-changeset "^2.3.0" - prosemirror-collab "^1.3.1" - prosemirror-commands "^1.6.2" - prosemirror-dropcursor "^1.8.1" - prosemirror-gapcursor "^1.3.2" - prosemirror-history "^1.4.1" - prosemirror-inputrules "^1.4.0" - prosemirror-keymap "^1.2.2" - prosemirror-markdown "^1.13.1" - prosemirror-menu "^1.2.4" - prosemirror-model "^1.24.1" - prosemirror-schema-basic "^1.2.3" - prosemirror-schema-list "^1.5.0" - prosemirror-state "^1.4.3" - prosemirror-tables "^1.6.4" - prosemirror-trailing-node "^3.0.0" - prosemirror-transform "^1.10.2" - prosemirror-view "^1.38.1" +"@tiptap/pm@^3.20.5", "@tiptap/pm@^3.27.3": + version "3.27.3" + resolved "https://registry.npmjs.org/@tiptap/pm/-/pm-3.27.3.tgz#b1c9673448db957ea6771cc75064985adca18756" + integrity sha512-ppiG57RxM3HSHHgcHT0hP6Ib4P56Sd5itxdV4w0hIGHKPGyLLKiAkYp+htIHa9T7IvjMdaqxIlsfyV3ZKsS1sw== + dependencies: + prosemirror-changeset "^2.4.1" + prosemirror-commands "^1.7.1" + prosemirror-dropcursor "^1.8.2" + prosemirror-gapcursor "^1.4.1" + prosemirror-history "^1.5.0" + prosemirror-inputrules "^1.5.1" + prosemirror-keymap "^1.2.3" + prosemirror-model "^1.25.9" + prosemirror-schema-list "^1.5.1" + prosemirror-state "^1.4.4" + prosemirror-tables "^1.8.5" + prosemirror-transform "^1.12.0" + prosemirror-view "^1.41.9" "@tiptap/react@^3.20.5": version "3.20.5" @@ -2488,19 +2473,6 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== -"@types/linkify-it@^5": - version "5.0.0" - resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-5.0.0.tgz#21413001973106cda1c3a9b91eedd4ccd5469d76" - integrity sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q== - -"@types/markdown-it@^14.0.0": - version "14.1.2" - resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-14.1.2.tgz#57f2532a0800067d9b934f3521429a2e8bfb4c61" - integrity sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog== - dependencies: - "@types/linkify-it" "^5" - "@types/mdurl" "^2" - "@types/mdast@^4.0.0": version "4.0.4" resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-4.0.4.tgz#7ccf72edd2f1aa7dd3437e180c64373585804dd6" @@ -2508,11 +2480,6 @@ dependencies: "@types/unist" "*" -"@types/mdurl@^2": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-2.0.0.tgz#d43878b5b20222682163ae6f897b20447233bdfd" - integrity sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg== - "@types/ms@*": version "2.1.0" resolved "https://registry.yarnpkg.com/@types/ms/-/ms-2.1.0.tgz#052aa67a48eccc4309d7f0191b7e41434b90bb78" @@ -2848,10 +2815,10 @@ ansi-styles@^4.1.0: dependencies: color-convert "^2.0.1" -apexcharts@5.14.0: - version "5.14.0" - resolved "https://registry.npmjs.org/apexcharts/-/apexcharts-5.14.0.tgz#01bb15967627dec4638e8ffb516d8ad5eac0e39c" - integrity sha512-hBLz5Gd8B5ZuocEzNUwEeKdw9vd7uy4OnqbDjAYyvwiAEyHk3OnO9+tGzAznpM6pnyCqWjPXUKKYoNIwdskeuQ== +apexcharts@5.16.0: + version "5.16.0" + resolved "https://registry.npmjs.org/apexcharts/-/apexcharts-5.16.0.tgz#f54348162bec24ab8acfb0744a38c2dd0c784502" + integrity sha512-ULO3Gqrm0/26uwBcYz49u181uIp8Fy6BQQgg9QUDajs+JAs/EbXxiAmdc6BLy1JY29/JXroM7P0j0t2JPDApdg== argparse@^1.0.7: version "1.0.10" @@ -3337,11 +3304,6 @@ cosmiconfig@^8.1.3: parse-json "^5.2.0" path-type "^4.0.0" -crelt@^1.0.0: - version "1.0.6" - resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.6.tgz#7cc898ea74e190fb6ef9dae57f8f81cf7302df72" - integrity sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g== - cross-spawn@^7.0.6: version "7.0.6" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" @@ -3571,10 +3533,10 @@ data-view-byte-offset@^1.0.1: es-errors "^1.3.0" is-data-view "^1.0.1" -date-fns@4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-4.1.0.tgz#64b3d83fff5aa80438f5b1a633c2e83b8a1c2d14" - integrity sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg== +date-fns@4.4.0: + version "4.4.0" + resolved "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz#806539edf45c616b2b76b5f78b88c56ed3c7e036" + integrity sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w== debug@4, debug@^4.0.0, debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.4.0, debug@^4.4.3: version "4.4.3" @@ -3996,12 +3958,12 @@ escape-string-regexp@^5.0.0: resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz#4683126b500b61762f2dbebace1806e8be31b1c8" integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw== -eslint-config-next@^16.2.3: - version "16.2.3" - resolved "https://registry.yarnpkg.com/eslint-config-next/-/eslint-config-next-16.2.3.tgz#0f271dc631d8dca6cbcdc59fbaab61130e7c8e28" - integrity sha512-Dnkrylzjof/Az7iNoIQJqD18zTxQZcngir19KJaiRsMnnjpQSVoa6aEg/1Q4hQC+cW90uTlgQYadwL1CYNwFWA== +eslint-config-next@^16.2.10: + version "16.2.10" + resolved "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.10.tgz#a4503d2a189c7542fb6d63f79e282fe747b857c4" + integrity sha512-HSybLOY0QKf39i4FWUqPN0xWiNDi6A6UqJmZtgDkS3zMqjXTqULvj/sueXx3cdCG0mVG+qH6k5/qdegklH1d1w== dependencies: - "@next/eslint-plugin-next" "16.2.3" + "@next/eslint-plugin-next" "16.2.10" eslint-import-resolver-node "^0.3.6" eslint-import-resolver-typescript "^3.5.2" eslint-plugin-import "^2.32.0" @@ -5222,10 +5184,10 @@ json5@^2.2.3: resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== -jspdf-autotable@^5.0.7: - version "5.0.7" - resolved "https://registry.yarnpkg.com/jspdf-autotable/-/jspdf-autotable-5.0.7.tgz#c5970646dd5ae18801d97e3e91625c95783efbe4" - integrity sha512-2wr7H6liNDBYNwt25hMQwXkEWFOEopgKIvR1Eukuw6Zmprm/ZcnmLTQEjW7Xx3FCbD3v7pflLcnMAv/h1jFDQw== +jspdf-autotable@^5.0.8: + version "5.0.8" + resolved "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-5.0.8.tgz#b010dab34caf5eff60bbcd09a36d0608dc0a84ae" + integrity sha512-Hy05N86yBO7CXBrnSLOge7i1ZYpKH2DjQ94iybaP7vBhSInjvRBgDc99ngKzSbSO8Jc98ZCally8I6n0tj2RJQ== jspdf@^4.2.0: version "4.2.1" @@ -5306,13 +5268,6 @@ lines-and-columns@^1.1.6: resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== -linkify-it@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-5.0.0.tgz#9ef238bfa6dc70bd8e7f9572b52d369af569b421" - integrity sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ== - dependencies: - uc.micro "^2.0.0" - linkifyjs@^4.3.2: version "4.3.2" resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-4.3.2.tgz#d97eb45419aabf97ceb4b05a7adeb7b8c8ade2b1" @@ -5389,18 +5344,6 @@ lru-cache@^5.1.1: dependencies: yallist "^3.0.2" -markdown-it@^14.0.0: - version "14.1.1" - resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-14.1.1.tgz#856f90b66fc39ae70affd25c1b18b581d7deee1f" - integrity sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA== - dependencies: - argparse "^2.0.1" - entities "^4.4.0" - linkify-it "^5.0.0" - mdurl "^2.0.0" - punycode.js "^2.3.1" - uc.micro "^2.1.0" - markdown-table@^3.0.0: version "3.0.4" resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-3.0.4.tgz#fe44d6d410ff9d6f2ea1797a3f60aa4d2b631c2a" @@ -5616,11 +5559,6 @@ mdn-data@2.0.30: resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.30.tgz#ce4df6f80af6cfbe218ecd5c552ba13c4dfa08cc" integrity sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA== -mdurl@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-2.0.0.tgz#80676ec0433025dd3e17ee983d0fe8de5a2237e0" - integrity sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w== - media-engine@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/media-engine/-/media-engine-1.0.3.tgz#be3188f6cd243ea2a40804a35de5a5b032f58dad" @@ -5999,26 +5937,26 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== -next@^16.2.2: - version "16.2.2" - resolved "https://registry.yarnpkg.com/next/-/next-16.2.2.tgz#7b02ce7ec5f2e17fc699ca2590820c779ae8282e" - integrity sha512-i6AJdyVa4oQjyvX/6GeER8dpY/xlIV+4NMv/svykcLtURJSy/WzDnnUk/TM4d0uewFHK7xSQz4TbIwPgjky+3A== +next@^16.2.10: + version "16.2.10" + resolved "https://registry.npmjs.org/next/-/next-16.2.10.tgz#54daa8d11b1b7146b37dc4094448e07eb0dff88b" + integrity sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA== dependencies: - "@next/env" "16.2.2" + "@next/env" "16.2.10" "@swc/helpers" "0.5.15" baseline-browser-mapping "^2.9.19" caniuse-lite "^1.0.30001579" postcss "8.4.31" styled-jsx "5.1.6" optionalDependencies: - "@next/swc-darwin-arm64" "16.2.2" - "@next/swc-darwin-x64" "16.2.2" - "@next/swc-linux-arm64-gnu" "16.2.2" - "@next/swc-linux-arm64-musl" "16.2.2" - "@next/swc-linux-x64-gnu" "16.2.2" - "@next/swc-linux-x64-musl" "16.2.2" - "@next/swc-win32-arm64-msvc" "16.2.2" - "@next/swc-win32-x64-msvc" "16.2.2" + "@next/swc-darwin-arm64" "16.2.10" + "@next/swc-darwin-x64" "16.2.10" + "@next/swc-linux-arm64-gnu" "16.2.10" + "@next/swc-linux-arm64-musl" "16.2.10" + "@next/swc-linux-x64-gnu" "16.2.10" + "@next/swc-linux-x64-musl" "16.2.10" + "@next/swc-win32-arm64-msvc" "16.2.10" + "@next/swc-win32-x64-msvc" "16.2.10" sharp "^0.34.5" no-case@^3.0.4: @@ -6359,21 +6297,14 @@ property-information@^7.0.0: resolved "https://registry.yarnpkg.com/property-information/-/property-information-7.1.0.tgz#b622e8646e02b580205415586b40804d3e8bfd5d" integrity sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ== -prosemirror-changeset@^2.3.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz#8d8ea0290cb9545c298ec427ac3a8f298c39170f" - integrity sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng== +prosemirror-changeset@^2.4.1: + version "2.4.1" + resolved "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz#685091245bf3299cd1cae2b8983cf9b0342e6b39" + integrity sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw== dependencies: prosemirror-transform "^1.0.0" -prosemirror-collab@^1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz#0e8c91e76e009b53457eb3b3051fb68dad029a33" - integrity sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ== - dependencies: - prosemirror-state "^1.0.0" - -prosemirror-commands@^1.0.0, prosemirror-commands@^1.6.2: +prosemirror-commands@^1.7.1: version "1.7.1" resolved "https://registry.yarnpkg.com/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz#d101fef85618b1be53d5b99ea17bee5600781b38" integrity sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w== @@ -6382,16 +6313,16 @@ prosemirror-commands@^1.0.0, prosemirror-commands@^1.6.2: prosemirror-state "^1.0.0" prosemirror-transform "^1.10.2" -prosemirror-dropcursor@^1.8.1: - version "1.8.2" - resolved "https://registry.yarnpkg.com/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz#2ed30c4796109ddeb1cf7282372b3850528b7228" - integrity sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw== +prosemirror-dropcursor@^1.8.2: + version "1.8.3" + resolved "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.3.tgz#726ae97baa251097306b0f7194a217a5ad9e8f9f" + integrity sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ== dependencies: prosemirror-state "^1.0.0" prosemirror-transform "^1.1.0" prosemirror-view "^1.1.0" -prosemirror-gapcursor@^1.3.2: +prosemirror-gapcursor@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz#da33c905fece147df577342c06f4929b25d365ee" integrity sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw== @@ -6401,7 +6332,7 @@ prosemirror-gapcursor@^1.3.2: prosemirror-state "^1.0.0" prosemirror-view "^1.0.0" -prosemirror-history@^1.0.0, prosemirror-history@^1.4.1: +prosemirror-history@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/prosemirror-history/-/prosemirror-history-1.5.0.tgz#ee21fc5de85a1473e3e3752015ffd6d649a06859" integrity sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg== @@ -6411,7 +6342,7 @@ prosemirror-history@^1.0.0, prosemirror-history@^1.4.1: prosemirror-view "^1.31.0" rope-sequence "^1.3.0" -prosemirror-inputrules@^1.4.0: +prosemirror-inputrules@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz#d2e935f6086e3801486b09222638f61dae89a570" integrity sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw== @@ -6419,7 +6350,7 @@ prosemirror-inputrules@^1.4.0: prosemirror-state "^1.0.0" prosemirror-transform "^1.0.0" -prosemirror-keymap@^1.0.0, prosemirror-keymap@^1.2.2, prosemirror-keymap@^1.2.3: +prosemirror-keymap@^1.0.0, prosemirror-keymap@^1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz#c0f6ab95f75c0b82c97e44eb6aaf29cbfc150472" integrity sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw== @@ -6427,40 +6358,14 @@ prosemirror-keymap@^1.0.0, prosemirror-keymap@^1.2.2, prosemirror-keymap@^1.2.3: prosemirror-state "^1.0.0" w3c-keyname "^2.2.0" -prosemirror-markdown@^1.13.1: - version "1.13.4" - resolved "https://registry.yarnpkg.com/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz#4620e6a0580cd52b5fc8e352c7e04830cd4b3048" - integrity sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw== - dependencies: - "@types/markdown-it" "^14.0.0" - markdown-it "^14.0.0" - prosemirror-model "^1.25.0" - -prosemirror-menu@^1.2.4: - version "1.3.0" - resolved "https://registry.yarnpkg.com/prosemirror-menu/-/prosemirror-menu-1.3.0.tgz#f51e25259b91d7c35ad7b65fc0c92d838404e177" - integrity sha512-TImyPXCHPcDsSka2/lwJ6WjTASr4re/qWq1yoTTuLOqfXucwF6VcRa2LWCkM/EyTD1UO3CUwiH8qURJoWJRxwg== - dependencies: - crelt "^1.0.0" - prosemirror-commands "^1.0.0" - prosemirror-history "^1.0.0" - prosemirror-state "^1.0.0" - -prosemirror-model@^1.0.0, prosemirror-model@^1.20.0, prosemirror-model@^1.21.0, prosemirror-model@^1.24.1, prosemirror-model@^1.25.0, prosemirror-model@^1.25.4: - version "1.25.4" - resolved "https://registry.yarnpkg.com/prosemirror-model/-/prosemirror-model-1.25.4.tgz#8ebfbe29ecbee9e5e2e4048c4fe8e363fcd56e7c" - integrity sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA== +prosemirror-model@^1.0.0, prosemirror-model@^1.21.0, prosemirror-model@^1.25.4, prosemirror-model@^1.25.8, prosemirror-model@^1.25.9: + version "1.25.10" + resolved "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.10.tgz#e097bc32a78a0600398dc94da3cfb0b02ad1cab0" + integrity sha512-9n6rH4DbJU1eH4SxLt6Y0HhJIo6cZsb7DJ/30uob1hOKPeO6TAaMWI2tc7kwR92BjfPOU2fFHWbZLovLi3XQfA== dependencies: orderedmap "^2.0.0" -prosemirror-schema-basic@^1.2.3: - version "1.2.4" - resolved "https://registry.yarnpkg.com/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz#389ce1ec09b8a30ea9bbb92c58569cb690c2d695" - integrity sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ== - dependencies: - prosemirror-model "^1.25.0" - -prosemirror-schema-list@^1.5.0: +prosemirror-schema-list@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz#5869c8f749e8745c394548bb11820b0feb1e32f5" integrity sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q== @@ -6469,7 +6374,7 @@ prosemirror-schema-list@^1.5.0: prosemirror-state "^1.0.0" prosemirror-transform "^1.7.3" -prosemirror-state@^1.0.0, prosemirror-state@^1.2.2, prosemirror-state@^1.4.3, prosemirror-state@^1.4.4: +prosemirror-state@^1.0.0, prosemirror-state@^1.2.2, prosemirror-state@^1.4.4: version "1.4.4" resolved "https://registry.yarnpkg.com/prosemirror-state/-/prosemirror-state-1.4.4.tgz#72b5e926f9e92dcee12b62a05fcc8a2de3bf5b39" integrity sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw== @@ -6478,7 +6383,7 @@ prosemirror-state@^1.0.0, prosemirror-state@^1.2.2, prosemirror-state@^1.4.3, pr prosemirror-transform "^1.0.0" prosemirror-view "^1.27.0" -prosemirror-tables@^1.6.4: +prosemirror-tables@^1.8.5: version "1.8.5" resolved "https://registry.yarnpkg.com/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz#104427012e5a5da1d2a38c122efee8d66bdd5104" integrity sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw== @@ -6489,27 +6394,19 @@ prosemirror-tables@^1.6.4: prosemirror-transform "^1.10.5" prosemirror-view "^1.41.4" -prosemirror-trailing-node@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz#5bc223d4fc1e8d9145e4079ec77a932b54e19e04" - integrity sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ== - dependencies: - "@remirror/core-constants" "3.0.0" - escape-string-regexp "^4.0.0" - -prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0, prosemirror-transform@^1.10.2, prosemirror-transform@^1.10.5, prosemirror-transform@^1.7.3: - version "1.11.0" - resolved "https://registry.yarnpkg.com/prosemirror-transform/-/prosemirror-transform-1.11.0.tgz#f5c5050354423dc83c6b083f6f1959ec86a3f9ba" - integrity sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw== +prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0, prosemirror-transform@^1.10.2, prosemirror-transform@^1.10.5, prosemirror-transform@^1.12.0, prosemirror-transform@^1.7.3: + version "1.12.0" + resolved "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz#0239288d0e98d91e6af3dd269a8968466be406d7" + integrity sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w== dependencies: prosemirror-model "^1.21.0" -prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.38.1, prosemirror-view@^1.41.4: - version "1.41.7" - resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.41.7.tgz#61e6f44ac160795c913ead92a282247df9d468f6" - integrity sha512-jUwKNCEIGiqdvhlS91/2QAg21e4dfU5bH2iwmSDQeosXJgKF7smG0YSplOWK0cjSNgIqXe7VXqo7EIfUFJdt3w== +prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.41.4, prosemirror-view@^1.41.9: + version "1.42.0" + resolved "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.0.tgz#82ed0823a1bf971d27cb647f3a23d5e005d04c00" + integrity sha512-N54DF3OXNWDuP81G1kbfCys8ZzIjuL1VnvJ2mk5STSu/fNxWIcX/EutQLA3s9KR/2wVhgDi4hzBB/1fINVxk0A== dependencies: - prosemirror-model "^1.20.0" + prosemirror-model "^1.25.8" prosemirror-state "^1.0.0" prosemirror-transform "^1.1.0" @@ -6518,11 +6415,6 @@ proxy-from-env@^2.1.0: resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz#a7487568adad577cfaaa7e88c49cab3ab3081aba" integrity sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA== -punycode.js@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/punycode.js/-/punycode.js-2.3.1.tgz#6b53e56ad75588234e79f4affa90972c7dd8cdb7" - integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA== - punycode@^2.1.0, punycode@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" @@ -6573,10 +6465,10 @@ raf@^3.4.1: dependencies: performance-now "^2.1.0" -react-apexcharts@2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/react-apexcharts/-/react-apexcharts-2.1.0.tgz#74f05b4bf1ad24114ed53c35823b62396c55a51f" - integrity sha512-xrmeTKRKHh3cvvLc8SasqFjlOgIqGpyHc81qjnRtcjUM0Fu7qEjgVRWGPokGFjqhwRZVgEym8zmuEyYr5LMYIg== +react-apexcharts@2.1.1: + version "2.1.1" + resolved "https://registry.npmjs.org/react-apexcharts/-/react-apexcharts-2.1.1.tgz#63c141072e64c03b3e07201b36264e4e4001f745" + integrity sha512-aV25n+1ZdAzRG0KhODVC48K8WbhMKBAr3yWSeaaBqM1ySn22kSSKblsNCUvlwegsT+cdMLuOrK9xGZb+IK+OkQ== dependencies: prop-types "^15.8.1" @@ -6614,10 +6506,10 @@ react-dropzone@15.0.0: file-selector "^2.1.0" prop-types "^15.8.1" -react-error-boundary@^6.1.1: - version "6.1.1" - resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-6.1.1.tgz#491d655e86c32434ede852755bb649119fdddd89" - integrity sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w== +react-error-boundary@^6.1.2: + version "6.1.2" + resolved "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.1.2.tgz#d213780329ceb3678cec7813a76a00e2a7584f48" + integrity sha512-3DpCr5HVdZ0caUjYE/kIHBEJN0mNP3ZCgf16c48uJ5TbWjorKVp+YG8W3XqlJ7vJAVNw6wNIImyPXmFydwmyng== react-fast-compare@^2.0.1: version "2.0.4" @@ -6654,12 +6546,7 @@ react-is@^17.0.2: resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== -react-is@^19.2.3: - version "19.2.4" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.4.tgz#a080758243c572ccd4a63386537654298c99d135" - integrity sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA== - -react-is@^19.2.4: +react-is@^19.2.3, react-is@^19.2.4: version "19.2.5" resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.5.tgz#7e7b54143e9313fed787b23fd4295d5a23872ad9" integrity sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ== @@ -6720,10 +6607,10 @@ react-quill@^2.0.0: lodash "^4.17.4" quill "^1.3.7" -"react-redux@8.x.x || 9.x.x", react-redux@9.2.0: - version "9.2.0" - resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-9.2.0.tgz#96c3ab23fb9a3af2cb4654be4b51c989e32366f5" - integrity sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g== +"react-redux@8.x.x || 9.x.x", react-redux@9.3.0: + version "9.3.0" + resolved "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz#a30113bb6d95c0a715d54dda4308d450fca6ce09" + integrity sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g== dependencies: "@types/use-sync-external-store" "^0.0.6" use-sync-external-store "^1.4.0" @@ -7664,11 +7551,6 @@ typescript-eslint@^8.46.0: "@typescript-eslint/typescript-estree" "8.57.1" "@typescript-eslint/utils" "8.57.1" -uc.micro@^2.0.0, uc.micro@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee" - integrity sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A== - unbox-primitive@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.1.0.tgz#8d9d2c9edeea8460c7f35033a88867944934d1e2"