diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000000..faf864265ea9 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,40 @@ +## Commit messages + +Generate all commit messages in **Conventional Commits** format: + +``` +(): + +[optional body] + +[optional footer(s)] +``` + +**Rules:** + +- **Type** is required and must be one of: + - `feat` — a new feature + - `fix` — a bug fix + - `docs` — documentation only + - `style` — formatting, whitespace, no code-behavior change + - `refactor` — code change that neither fixes a bug nor adds a feature + - `perf` — a performance improvement + - `test` — adding or correcting tests + - `build` — build system or dependency changes + - `ci` — CI/CD configuration changes + - `chore` — routine maintenance, no production code change + - `revert` — reverts a previous commit +- **Scope** is optional and given in parentheses after the type (e.g. `feat(identity):`). Use a short, lowercase area name when it adds clarity. +- **Description** is a short, imperative-mood summary ("add", not "added"/"adds"), lowercase, no trailing period, ideally ≤ 72 characters. +- **Body** (optional) explains the *what* and *why*, not the *how*. Separate it from the description with one blank line. +- **Breaking changes** are indicated with a `!` before the colon (e.g. `feat!:`) and/or a `BREAKING CHANGE:` footer describing the change. +- Reference issues/PRs in the footer where relevant (e.g. `Closes #123`). + +**Examples:** + +``` +feat(identity): add bulk user offboarding endpoint +fix(graph): handle expired token on retry +docs: update authentication model overview +refactor(standards)!: rename remediation parameter +``` diff --git a/.github/scripts/validate-json.mjs b/.github/scripts/validate-json.mjs new file mode 100644 index 000000000000..0f626b1c4ba1 --- /dev/null +++ b/.github/scripts/validate-json.mjs @@ -0,0 +1,71 @@ +import { readFile, readdir, writeFile, appendFile } from "node:fs/promises"; +import path from "node:path"; + +// Usage: node validate-json.mjs [--strip ] [dir...] +// --strip removes a leading path prefix from reported filenames, so a PR checked +// out into a subdirectory still reports repo-relative paths. +const argv = process.argv.slice(2); +let strip = ""; +const roots = []; +for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--strip") { + strip = argv[++i] ?? ""; + } else { + roots.push(argv[i]); + } +} + +async function collect(dir) { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch (error) { + if (error.code === "ENOENT") return []; + throw error; + } + const files = []; + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules") continue; + files.push(...(await collect(full))); + } else if (entry.name.endsWith(".json")) { + files.push(full); + } + } + return files; +} + +const report = (file) => { + const normalised = file.split(path.sep).join("/"); + return strip && normalised.startsWith(strip) ? normalised.slice(strip.length) : normalised; +}; + +const failures = []; +let checked = 0; + +for (const root of roots) { + for (const file of await collect(root)) { + checked++; + const contents = await readFile(file, "utf8"); + try { + JSON.parse(contents); + } catch (error) { + failures.push({ file: report(file), message: error.message.replace(/\r?\n/g, " ") }); + } + } +} + +for (const { file, message } of failures) { + // Annotate the PR diff via a GitHub Actions error command. + console.log(`::error file=${file}::${message}`); +} +console.log(`Checked ${checked} JSON file(s), ${failures.length} invalid.`); + +// Hand the results to the workflow so it can comment on the PR. +if (process.env.GITHUB_OUTPUT) { + await appendFile(process.env.GITHUB_OUTPUT, `invalid_count=${failures.length}\n`); +} +await writeFile("json-validation-results.json", JSON.stringify(failures, null, 2)); + +process.exit(failures.length > 0 ? 1 : 0); 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..a5a669b02e55 --- /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)(\(.+\))?!?: .+/i; + + 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/Detect_Duplicate_Issues.yml b/.github/workflows/Detect_Duplicate_Issues.yml deleted file mode 100644 index e6f8e1d8fbd8..000000000000 --- a/.github/workflows/Detect_Duplicate_Issues.yml +++ /dev/null @@ -1,24 +0,0 @@ ---- -name: Detect Duplicate Issues -on: - issues: - types: - - opened - - reopened - -permissions: - models: read - issues: write - -jobs: - detect-duplicates: - if: github.repository_owner == 'KelvinTegelaar' && github.event.issue.user.type != 'Bot' - runs-on: ubuntu-latest - steps: - - name: Calculate lookback date - id: lookback - run: echo "since=$(date -u -d '60 days ago' +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" - - uses: pelikhan/action-genai-issue-dedup@v0 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - since: ${{ steps.lookback.outputs.since }} diff --git a/.github/workflows/Node_Project_Check.yml b/.github/workflows/Node_Project_Check.yml index 3347edfbe84d..1d675b9fed7c 100644 --- a/.github/workflows/Node_Project_Check.yml +++ b/.github/workflows/Node_Project_Check.yml @@ -20,7 +20,7 @@ jobs: steps: - uses: actions/checkout@v6 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v6.4.0 + uses: actions/setup-node@v7.0.0 with: node-version: ${{ matrix.node-version }} - name: Install and Build Test diff --git a/.github/workflows/Validate_JSON.yml b/.github/workflows/Validate_JSON.yml new file mode 100644 index 000000000000..9db12dd5dc03 --- /dev/null +++ b/.github/workflows/Validate_JSON.yml @@ -0,0 +1,117 @@ +--- +name: Validate JSON +on: + # pull_request_target (not pull_request) so the token can comment on fork PRs. + # The PR's own code is never executed: it is checked out into ./pr as data only, + # and parsed by the validator script from the trusted base checkout. + pull_request_target: + types: [opened, synchronize, reopened] + branches: + - main + - dev + paths: + - "public/**/*.json" + - "src/data/**/*.json" + - ".github/workflows/Validate_JSON.yml" + - ".github/scripts/validate-json.mjs" + push: + branches: + - dev + paths: + - "public/**/*.json" + - "src/data/**/*.json" + - ".github/workflows/Validate_JSON.yml" + - ".github/scripts/validate-json.mjs" +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || github.ref }} + cancel-in-progress: true +permissions: + contents: read + pull-requests: write +jobs: + validate: + name: Parse JSON in public and src/data + runs-on: ubuntu-latest + steps: + - name: Checkout base (trusted validator script) + uses: actions/checkout@v6 + + - name: Checkout PR head (untrusted, data only) + if: github.event_name == 'pull_request_target' + uses: actions/checkout@v6 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + path: pr + persist-credentials: false + + - name: Validate JSON files + id: validate + continue-on-error: true + env: + ROOT: ${{ github.event_name == 'pull_request_target' && 'pr/' || '' }} + run: node .github/scripts/validate-json.mjs --strip "$ROOT" "${ROOT}public" "${ROOT}src/data" + + - name: Comment on PR + if: github.event_name == 'pull_request_target' + uses: actions/github-script@v9 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + const marker = ''; + const resultsFile = 'json-validation-results.json'; + if (!fs.existsSync(resultsFile)) { + // The validator crashed before reporting; let its own error stand. + core.warning('No validation results found — skipping PR comment.'); + return; + } + const failures = JSON.parse(fs.readFileSync(resultsFile, 'utf8')); + + // Find a previous comment from this workflow so we update instead of piling up. + const { data: comments } = await github.rest.issues.listComments({ + ...context.repo, + issue_number: context.issue.number, + per_page: 100, + }); + const existing = comments.find( + (c) => c.user.type === 'Bot' && c.body.includes(marker) + ); + + let body; + if (failures.length > 0) { + const list = failures + .map(({ file, message }) => `- \`${file}\`\n > ${message}`) + .join('\n'); + body = + `${marker}\n### ⚠️ Invalid JSON detected\n\n` + + `${failures.length} JSON file(s) in this PR could not be parsed. ` + + `These files are loaded directly by CIPP, so a syntax error here breaks the app at runtime.\n\n` + + `${list}\n\n` + + `Please fix the syntax and push again — this comment will update automatically.`; + } else if (existing) { + body = `${marker}\n### ✅ JSON is valid\n\nAll JSON files in \`public\` and \`src/data\` parse correctly. Thanks for fixing it!`; + } else { + // Nothing was ever broken — stay quiet. + return; + } + + if (existing) { + await github.rest.issues.updateComment({ + ...context.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + ...context.repo, + issue_number: context.issue.number, + body, + }); + } + + - name: Fail if any JSON is invalid + if: steps.validate.outputs.invalid_count != '0' + run: | + echo "::error::${{ steps.validate.outputs.invalid_count }} invalid JSON file(s). See annotations above." + exit 1 diff --git a/.github/workflows/cipp_dev_build.yml b/.github/workflows/cipp_dev_build.yml index c72249f2eff1..39cd58108cb1 100644 --- a/.github/workflows/cipp_dev_build.yml +++ b/.github/workflows/cipp_dev_build.yml @@ -26,7 +26,7 @@ jobs: echo "node_version=$node_sanitized_version" >> $GITHUB_OUTPUT - name: Set up Node.js - uses: actions/setup-node@v6.4.0 + uses: actions/setup-node@v7.0.0 with: node-version: ${{ steps.get_node_version.outputs.node_version }} @@ -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..a09cc2565105 100644 --- a/.github/workflows/cipp_frontend_build.yml +++ b/.github/workflows/cipp_frontend_build.yml @@ -26,7 +26,7 @@ jobs: echo "node_version=$node_sanitized_version" >> $GITHUB_OUTPUT - name: Set up Node.js - uses: actions/setup-node@v6.4.0 + uses: actions/setup-node@v7.0.0 with: node-version: ${{ steps.get_node_version.outputs.node_version }} @@ -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/.npmrc b/.npmrc index 12937d4c275f..3d47a5118882 100644 --- a/.npmrc +++ b/.npmrc @@ -1,5 +1,6 @@ # Supply-chain hardening for CIPP -# This file is honored by BOTH npm and yarn (yarn classic reads .npmrc). +# npm honors this file. yarn classic does NOT read `ignore-scripts` from +# .npmrc (only registry/auth keys); the yarn equivalents live in .yarnrc. # Any change here should be reviewed for CI/CD impact. # Refuse to execute package lifecycle scripts (pre/postinstall, prepare, etc.) @@ -12,13 +13,13 @@ ignore-scripts=true # CI / contributor installs to a malicious mirror. registry=https://registry.npmjs.org/ -# Require integrity hashes (sha512) to match the lockfile on install. -# npm honors this directly; yarn classic always verifies lockfile integrity -# but this makes the intent explicit. +# Severity threshold at which `npm audit` exits non-zero. (Integrity hashes +# are always verified against the lockfile on install; that needs no flag.) audit-level=high -# Don't auto-save changes to the lockfile from arbitrary install commands. -# Lockfile edits should only happen via Dependabot PRs or explicit upgrades. +# Write exact versions to package.json when adding deps via `npm install `, +# so a future lockfile regeneration cannot drift to a newer release. +# npm-only; the yarn equivalent is `yarn add --exact`. save-exact=true # Disable funding/notifier noise so CI logs only show real signal. diff --git a/.yarnrc b/.yarnrc index c0a88a3c68ee..8dda1e5efe50 100644 --- a/.yarnrc +++ b/.yarnrc @@ -1,8 +1,8 @@ # Supply-chain hardening for CIPP (yarn 1 / classic) # -# This complements .npmrc — yarn 1 honors `ignore-scripts` from .npmrc, but -# we set the per-command equivalents here as defense in depth so the -# protection survives even if .npmrc is missing or ignored. +# This complements .npmrc but is not redundant with it: yarn 1 does NOT +# honor `ignore-scripts` from .npmrc, so for yarn installs these per-command +# flags are the protection, not defense in depth. # Refuse to execute lifecycle scripts on `yarn install` / `yarn add` / # `yarn upgrade`. Mirrors `ignore-scripts=true` in .npmrc. diff --git a/package.json b/package.json index 94b04e986410..7b080b29ffd2 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "cipp", - "version": "10.5.2", + "version": "10.6.4", "author": "CIPP Contributors", "homepage": "https://cipp.app/", "bugs": { - "url": "https://github.com/KelvinTegelaar/CIPP/issues" + "url": "https://github.com/CyberDrain/CIPP/issues" }, "license": "AGPL-3.0", "engines": { @@ -12,7 +12,7 @@ }, "repository": { "type": "git", - "url": "git@github.com:KelvinTegelaar/CIPP.git" + "url": "git@github.com:CyberDrain/CIPP.git" }, "scripts": { "dev": "next -H 127.0.0.1", @@ -42,22 +42,22 @@ "@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", - "axios": "1.16.1", - "date-fns": "4.1.0", - "diff": "^8.0.3", - "dompurify": "^3.4.9", + "apexcharts": "5.16.0", + "axios": "1.18.1", + "date-fns": "4.4.0", + "diff": "^9.0.0", + "dompurify": "^3.4.12", "driver.js": "^1.4.0", "eml-parse-js": "^1.2.0-beta.0", "export-to-csv": "^1.3.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,8 +112,9 @@ "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" + "prettier": "^3.9.5", + "typescript": "5.9.3" } } diff --git a/public/assets/logos/sharepoint.svg b/public/assets/logos/sharepoint.svg new file mode 100644 index 000000000000..15425374232e --- /dev/null +++ b/public/assets/logos/sharepoint.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + diff --git a/public/assets/logos/teams.svg b/public/assets/logos/teams.svg new file mode 100644 index 000000000000..c45b1a623ffc --- /dev/null +++ b/public/assets/logos/teams.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/version.json b/public/version.json index 326768d361ba..89582d8a5d3d 100644 --- a/public/version.json +++ b/public/version.json @@ -1,3 +1,3 @@ { - "version": "10.5.2" -} + "version": "10.6.4" +} \ No newline at end of file diff --git a/src/components/BECRemediationReportButton.js b/src/components/BECRemediationReportButton.js index e8e9ddec81c0..cf77b3b842cc 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} @@ -1418,10 +1541,7 @@ export const BECRemediationReportButton = ({ userData, becData, tenantName }) => // Check if we have the necessary data const hasData = userData && becData && !becData.Waiting - const brandingSettings = userSettings?.organizationSettings || { - logo: userSettings?.organizationSettings?.logo, - colour: userSettings?.organizationSettings?.colour || '#F77F00', - } + const brandingSettings = userSettings?.customBranding const handleOpenDialog = () => { setDialogOpen(true) 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..1782b9e9acb7 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, @@ -100,20 +109,21 @@ export const CippChartCard = ({ const [barSeries, setBarSeries] = useState([]); const chartOptions = useChartOptions(labels, chartType); chartSeries = chartSeries.filter((item) => item !== null); - const calculatedTotal = chartSeries.reduce((acc, value) => acc + value, 0); + // Round to 2 decimals - summing fractional series values accumulates floating-point + // artifacts (e.g. 175.73000000000002). Integer series are unaffected. + const calculatedTotal = Math.round(chartSeries.reduce((acc, value) => acc + value, 0) * 100) / 100; 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/AppApprovalTemplateForm.jsx b/src/components/CippComponents/AppApprovalTemplateForm.jsx index 20db0c4c48cd..10948b97f1d7 100644 --- a/src/components/CippComponents/AppApprovalTemplateForm.jsx +++ b/src/components/CippComponents/AppApprovalTemplateForm.jsx @@ -7,6 +7,7 @@ import { Grid } from "@mui/system"; import CippPermissionPreview from "./CippPermissionPreview"; import { useWatch } from "react-hook-form"; import { CippPermissionSetDrawer } from "./CippPermissionSetDrawer"; +import { ApiGetCall } from "../../api/ApiCall"; const AppApprovalTemplateForm = ({ formControl, @@ -25,6 +26,12 @@ const AppApprovalTemplateForm = ({ const [permissionSetDrawerVisible, setPermissionSetDrawerVisible] = useState(false); const [manifestSanitizeMessage, setManifestSanitizeMessage] = useState(null); + // Shares its queryKey with the permission set autocomplete below, so this reuses that cache. + const permissionSets = ApiGetCall({ + url: "/api/ExecAppPermissionTemplate", + queryKey: "execAppPermissionTemplate", + }); + const getManifestValidationError = (manifest) => { if (!manifest.displayName) { return "Application manifest must include a 'displayName' property"; @@ -273,6 +280,31 @@ const AppApprovalTemplateForm = ({ } }, [templateData, isCopy, isEditing, formControl]); + // A template stores a copy of the permission set taken when it was last saved, and that copy goes + // stale as soon as the set is edited. Re-seed from the linked set once it loads so the preview and + // the saved payload match the permissions deployment actually consents. + useEffect(() => { + if (!formControl || !(isEditing || isCopy)) return; + + const template = templateData?.[0]; + if (!template?.PermissionSetId) return; + + const liveSet = permissionSets.data?.find((set) => set.TemplateId === template.PermissionSetId); + if (!liveSet) return; + + const permissionSetValue = { + label: liveSet.TemplateName || template.PermissionSetName || "Custom Permissions", + value: template.PermissionSetId, + addedFields: { + Permissions: liveSet.Permissions || {}, + }, + }; + + formControl.setValue("permissionSetId", permissionSetValue, { shouldDirty: false }); + setSelectedPermissionSet(permissionSetValue); + setPermissionsLoaded(true); + }, [permissionSets.data, templateData, isEditing, isCopy, formControl]); + useEffect(() => { if (!formControl) return; // Early return if formControl is not available 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 97ade0f8d6c6..4b9dd4b4828e 100644 --- a/src/components/CippComponents/AuthMethodCard.jsx +++ b/src/components/CippComponents/AuthMethodCard.jsx @@ -16,8 +16,23 @@ export const AuthMethodCard = ({ data, isLoading }) => { return null; } - const phishableMethods = ["mobilePhone", "email", "microsoftAuthenticatorPush"]; - const phishResistantMethods = ["fido2", "windowsHelloForBusiness", "x509Certificate"]; + const phishableMethods = [ + "mobilePhone", + "alternateMobilePhone", + "officePhone", + "email", + "microsoftAuthenticatorPush", + "softwareOneTimePasscode", + "hardwareOneTimePasscode", + ]; + const passkeyMethods = [ + "fido2SecurityKey", + "passKeyDeviceBound", + "passKeyDeviceBoundAuthenticator", + "passKeyDeviceBoundWindowsHello", + "x509Certificate", + ]; + const phishResistantMethods = [...passkeyMethods, "windowsHelloForBusiness"]; let singleFactor = 0; let phishableCount = 0; @@ -48,7 +63,7 @@ export const AuthMethodCard = ({ data, isLoading }) => { if (hasPhishResistant) { phishResistantCount++; - if (methods.includes("fido2") || methods.includes("x509Certificate")) { + if (methods.some((m) => passkeyMethods.includes(m))) { passkeyCount++; } if (methods.includes("windowsHelloForBusiness")) { @@ -56,12 +71,18 @@ export const AuthMethodCard = ({ data, isLoading }) => { } } else if (hasPhishable) { phishableCount++; - if (methods.includes("mobilePhone") || methods.includes("email")) { + if ( + methods.includes("mobilePhone") || + methods.includes("alternateMobilePhone") || + methods.includes("officePhone") || + methods.includes("email") + ) { phoneCount++; } if ( methods.includes("microsoftAuthenticatorPush") || - methods.includes("softwareOneTimePasscode") + methods.includes("softwareOneTimePasscode") || + methods.includes("hardwareOneTimePasscode") ) { authenticatorCount++; } @@ -196,7 +217,17 @@ export const AuthMethodCard = ({ data, isLoading }) => { + router.push("/identity/reports/mfa-report")} + sx={{ + display: "flex", + alignItems: "center", + gap: 1, + cursor: "pointer", + width: "fit-content", + "&:hover": { textDecoration: "underline" }, + }} + > All users auth methods diff --git a/src/components/CippComponents/AuthMethodSankey.jsx b/src/components/CippComponents/AuthMethodSankey.jsx index f57c42573c52..f65ec13c1483 100644 --- a/src/components/CippComponents/AuthMethodSankey.jsx +++ b/src/components/CippComponents/AuthMethodSankey.jsx @@ -13,9 +13,23 @@ export const AuthMethodSankey = ({ data }) => { return null; } - // Categorize MFA methods as phishable or phish-resistant - const phishableMethods = ["mobilePhone", "email", "microsoftAuthenticatorPush"]; - const phishResistantMethods = ["fido2", "windowsHelloForBusiness", "x509Certificate"]; + const phishableMethods = [ + "mobilePhone", + "alternateMobilePhone", + "officePhone", + "email", + "microsoftAuthenticatorPush", + "softwareOneTimePasscode", + "hardwareOneTimePasscode", + ]; + const passkeyMethods = [ + "fido2SecurityKey", + "passKeyDeviceBound", + "passKeyDeviceBoundAuthenticator", + "passKeyDeviceBoundWindowsHello", + "x509Certificate", + ]; + const phishResistantMethods = [...passkeyMethods, "windowsHelloForBusiness"]; let singleFactor = 0; let phishableCount = 0; @@ -54,7 +68,7 @@ export const AuthMethodSankey = ({ data }) => { if (hasPhishResistant) { phishResistantCount++; // Count specific phish-resistant methods - if (methods.includes("fido2") || methods.includes("x509Certificate")) { + if (methods.some((m) => passkeyMethods.includes(m))) { passkeyCount++; } if (methods.includes("windowsHelloForBusiness")) { @@ -62,13 +76,18 @@ export const AuthMethodSankey = ({ data }) => { } } else if (hasPhishable) { phishableCount++; - // Count specific phishable methods - if (methods.includes("mobilePhone") || methods.includes("email")) { + if ( + methods.includes("mobilePhone") || + methods.includes("alternateMobilePhone") || + methods.includes("officePhone") || + methods.includes("email") + ) { phoneCount++; } if ( methods.includes("microsoftAuthenticatorPush") || - methods.includes("softwareOneTimePasscode") + methods.includes("softwareOneTimePasscode") || + methods.includes("hardwareOneTimePasscode") ) { authenticatorCount++; } diff --git a/src/components/CippComponents/CippAlertSnoozeDialog.jsx b/src/components/CippComponents/CippAlertSnoozeDialog.jsx index f94e5fe62dae..cc5638433411 100644 --- a/src/components/CippComponents/CippAlertSnoozeDialog.jsx +++ b/src/components/CippComponents/CippAlertSnoozeDialog.jsx @@ -10,10 +10,15 @@ import { Radio, Typography, Box, - Alert, + Stack, } from '@mui/material' import { ApiPostCall } from '../../api/ApiCall' import { CippApiResults } from './CippApiResults' +import { + describeAlertItem, + getAlertItemFields, + humanizeCmdlet, +} from '../../utils/format-alert-item' const SNOOZE_OPTIONS = [ { value: '7', label: 'Snooze for 7 days' }, @@ -56,23 +61,53 @@ export const CippAlertSnoozeDialog = ({ onClose() } - // Build a preview of the alert item - const preview = - alertItem?.UserPrincipalName || - alertItem?.Message || - alertItem?.DisplayName || - (alertItem ? JSON.stringify(alertItem).substring(0, 120) : '') + const fields = getAlertItemFields(alertItem) + const { title } = describeAlertItem(alertItem) + const alertLabel = humanizeCmdlet(cmdletName) return ( Snooze Alert - {preview && ( - - - {preview} + {alertItem && ( + + + {alertLabel} - + {fields.length > 0 ? ( + + {fields.map((field) => ( + + + {field.label} + + + {field.value} + + + ))} + + ) : ( + + {title} + + )} + )} {!submitted ? ( diff --git a/src/components/CippComponents/CippAnonymizedReportAlert.jsx b/src/components/CippComponents/CippAnonymizedReportAlert.jsx new file mode 100644 index 000000000000..aca346effff2 --- /dev/null +++ b/src/components/CippComponents/CippAnonymizedReportAlert.jsx @@ -0,0 +1,84 @@ +import { Alert, Link } from '@mui/material' +import { ApiGetCallWithPagination } from '../../api/ApiCall' +import { useSettings } from '../../hooks/use-settings' + +// When M365 "conceal user/site names in reports" is enabled, Graph usage reports return +// 32-char hex hashes (e.g. 85926F73B5A9FA60D166E6057BA76F4A) instead of names/UPNs. +const HASHED_NAME = /^[0-9A-F]{32}$/i +const DEFAULT_FIELDS = [ + 'userPrincipalName', + 'displayName', + 'ownerPrincipalName', + 'ownerDisplayName', + 'UPN', + 'userId', + 'UserId', + 'siteUrl', +] + +/** + * True when report rows look anonymized: for at least one of the given fields, every + * populated value across all rows is a 32-char hex hash. + */ +export const isReportAnonymized = (rows, fields = DEFAULT_FIELDS) => { + if (!Array.isArray(rows) || rows.length === 0) return false + return fields.some((field) => { + const values = rows + .map((row) => row?.[field]) + .filter((value) => typeof value === 'string' && value !== '') + return values.length > 0 && values.every((value) => HASHED_NAME.test(value)) + }) +} + +/** + * Hook for CippTablePage-based report pages. Subscribes to the same React Query cache entry + * as the page's table (same url/data/queryKey → one shared request) and returns whether the + * loaded rows look anonymized. + * + * @param {string} url - Same apiUrl the table uses. + * @param {Object} [data] - Same extra apiData the table uses (tenantFilter is added here). + * @param {string} queryKey - Same queryKey the table uses (must match exactly). + * @param {string} [dataKey] - Same apiDataKey the table uses (e.g. "Results"). + * @param {string[]} [fields] - Name fields to test for hashes. + * @param {Function} [check] - Optional custom predicate (rows) => bool, replaces the hash test. + */ +export const useReportAnonymized = ({ url, data, queryKey, dataKey, fields, check }) => { + const tenant = useSettings().currentTenant + const query = ApiGetCallWithPagination({ + url, + data: { tenantFilter: tenant, ...data }, + queryKey, + }) + if (!query.isSuccess || query.isFetching) return false + const rows = (query.data?.pages ?? []).flatMap((page) => { + const pageData = dataKey ? page?.[dataKey] : page + return Array.isArray(pageData) ? pageData : [] + }) + if (check) return rows.length > 0 && check(rows) + return isReportAnonymized(rows, fields) +} + +/** + * Warning banner shown when report data is anonymized. Renders nothing unless `show`. + * Pass `children` to override only the lead-in sentence (e.g. the all-zero-storage variant); + * the standard link and guidance are always appended. + */ +export const CippAnonymizedReportAlert = ({ show, children }) => { + if (!show) return null + return ( + + {children ?? + 'Names in this report appear pseudo-anonymised because Microsoft 365 report anonymization is enabled for this tenant.'}{' '} + The{' '} + + Enable Usernames instead of pseudo anonymised names in reports + {' '} + standard might need to be enabled to have the data populate correctly. Re-run the report sync + after changing the setting. + + ) +} 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..f14e6a489f7b 100644 --- a/src/components/CippComponents/CippApiResults.jsx +++ b/src/components/CippComponents/CippApiResults.jsx @@ -1,6 +1,16 @@ -import { Close, Download, Help, ExpandMore, ExpandLess } from "@mui/icons-material"; +import { + Close, + Download, + Help, + ExpandMore, + ExpandLess, + CheckCircle, + Error as ErrorIcon, + RadioButtonUnchecked, +} from '@mui/icons-material' import { Alert, + Chip, CircularProgress, Collapse, IconButton, @@ -11,40 +21,41 @@ 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"; - -const extractAllResults = (data) => { - const results = []; +} from '@mui/material' +import { useEffect, useState, useMemo, useCallback } from 'react' +import { ApiGetCall } from '../../api/ApiCall' +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, extraIgnoreKeys = []) => { + 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 +64,253 @@ 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', ...extraIgnoreKeys] - 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 +} + +const capitalize = (text) => + typeof text === 'string' && text.length > 0 ? text.charAt(0).toUpperCase() + text.slice(1) : text + +const JOB_STATUS_CHIP_COLORS = { + queued: 'default', + running: 'info', + succeeded: 'success', + failed: 'error', +} - extractFrom(data); - return results; -}; +// Status icon for a single job step. +const JobStepIcon = ({ status }) => { + if (status === 'succeeded') return + if (status === 'failed') return + if (status === 'running') return + return +} + +// Live job progress rows (GDAP-onboarding style): one block per row (usually a tenant) with +// its steps, driven by the jobProgress polling in CippApiResults. +const CippJobProgress = ({ rows }) => ( + + {rows.map((row, rowIndex) => ( + + + {row.Tenant ?? row.Name} + + + + {(row.Steps || []).map((step, index) => ( + + + + + + {step.Title} + + {step.Message} + + + + ))} + + + ))} + +) export const CippApiResults = (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 { apiObject, errorsOnly = false, alertSx = {}, jobProgress = null } = props + + const [errorVisible, setErrorVisible] = useState(false) + const [fetchingVisible, setFetchingVisible] = useState(false) + const [finalResults, setFinalResults] = useState([]) + const [showDetails, setShowDetails] = useState({}) + const [jobId, setJobId] = useState(null) + const [jobPollActive, setJobPollActive] = useState(false) + const tableDialog = useDialog() + + // Optional live job progress: when the mutation result carries jobProgress.idField, poll + // jobProgress.url(id) until every row reaches a terminal state. + const jobIdField = jobProgress?.idField ?? 'JobId' + useEffect(() => { + if (!jobProgress) return + if (apiObject.isPending) { + setJobId(null) + setJobPollActive(false) + return + } + if (!apiObject.isSuccess) return + const raw = apiObject?.data?.data ?? apiObject?.data + const item = Array.isArray(raw) ? raw[0] : raw + const id = item?.[jobIdField] + if (id) { + setJobId(id) + setJobPollActive(true) + } + }, [jobProgress, jobIdField, apiObject.isPending, apiObject.isSuccess, apiObject.data]) + + const jobStatus = ApiGetCall({ + url: jobProgress && jobId ? jobProgress.url(jobId) : null, + queryKey: `CippJobProgress-${jobId}`, + waiting: !!(jobProgress && jobId), + refetchInterval: jobPollActive ? (jobProgress?.interval ?? 5000) : false, + staleTime: 0, + }) + const jobRows = Array.isArray(jobStatus.data) ? jobStatus.data : [] + useEffect(() => { + if ( + jobPollActive && + jobRows.length > 0 && + jobRows.every((row) => row.Status === 'succeeded' || row.Status === 'failed') + ) { + setJobPollActive(false) + } + }, [jobPollActive, jobRows]) + 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] + // Don't render the job id (e.g. DeploymentId) as a result alert of its own. + const jobIgnoreKeys = jobProgress ? [jobIdField] : [] + const apiResults = sourceItems.flatMap((item, groupIndex) => + extractAllResults(item, jobIgnoreKeys).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, jobProgress, jobIdField]) 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 +321,10 @@ export const CippApiResults = (props) => { severity: res.severity, visible: true, ...res, - })), - ); + })) + ) } else { - setFinalResults([]); + setFinalResults([]) } }, [ apiObject.isError, @@ -213,38 +333,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]); - - const hasVisibleResults = finalResults.some((r) => r.visible); + 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 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 +397,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 +424,24 @@ export const CippApiResults = (props) => { - {resultObj.severity === "error" && ( + {resultObj.severity === 'error' && ( + + + {loadError && {loadError}} + + {access.isFetching && } + + {!access.isFetching && data && ( + <> + + {data.HasAccess ? ( + + + {data.DisplayName} has access via {data.AccessPathCount}{' '} + {data.AccessPathCount === 1 ? 'route' : 'routes'} + + Removing one route does not remove the others — every route below has to go for + access to stop. + + ) : ( + + {data.DisplayName} has no access + {limitedOnly + ? 'The only entry found is Limited Access, which SharePoint adds so a user can traverse to a specific item. It does not let them open or list anything here.' + : 'No permission, group membership or sharing link grants this user access to this scope.'} + + )} + + {data.LibraryInherits && ( + + This library inherits its permissions from the site, so the site's + permissions were evaluated. + + )} + + + + {data.IsGuest && ( + + )} + {!data.SharingLinksChecked && ( + + )} + + + + + )} + + + + + + + ) +} + +export default CippCheckUserAccessDialog diff --git a/src/components/CippComponents/CippContactPermissionsDialog.jsx b/src/components/CippComponents/CippContactPermissionsDialog.jsx index 44e1ce303871..6d0e0e9c7c2a 100644 --- a/src/components/CippComponents/CippContactPermissionsDialog.jsx +++ b/src/components/CippComponents/CippContactPermissionsDialog.jsx @@ -1,25 +1,41 @@ -import { useEffect } from "react"; -import { Box, Stack, Tooltip } from "@mui/material"; -import CippFormComponent from "./CippFormComponent"; -import { useWatch } from "react-hook-form"; +import { useEffect } from 'react' +import { Box, FormControlLabel, Stack, Switch, Tooltip } from '@mui/material' +import CippFormComponent from './CippFormComponent' +import { useWatch } from 'react-hook-form' +import { GroupHeader, GroupItems } from './CippAutocompleteGrouping' -const CippContactPermissionsDialog = ({ formHook, combinedOptions, isUserGroupLoading }) => { +const CippContactPermissionsDialog = ({ + formHook, + combinedOptions, + isUserGroupLoading, + includeGroups, + onIncludeGroupsChange, +}) => { const permissionLevel = useWatch({ control: formHook.control, - name: "Permissions", - }); + name: 'Permissions', + }) // default SendNotificationToUser to false on mount useEffect(() => { - formHook.setValue("SendNotificationToUser", false); - }, [formHook]); + formHook.setValue('SendNotificationToUser', false) + }, [formHook]) // Only certain permission levels support sending a notification when contact permissions are added - const notifyAllowed = ["AvailabilityOnly", "LimitedDetails", "Reviewer", "Editor"]; - const isNotifyAllowed = notifyAllowed.includes(permissionLevel?.value ?? permissionLevel); + const notifyAllowed = ['AvailabilityOnly', 'LimitedDetails', 'Reviewer', 'Editor'] + const isNotifyAllowed = notifyAllowed.includes(permissionLevel?.value ?? permissionLevel) return ( + onIncludeGroupsChange(checked)} + /> + } + label="Include mail-enabled security groups" + /> option.group} + renderGroup={(params) => ( +
  • + {params.group} + {params.children} +
  • + )} creatable={false} - validators={{ required: "Select a user or group to assign permissions to" }} + validators={{ required: 'Select a user or group to assign permissions to' }} placeholder="Select a user or group to assign permissions to" />
    @@ -41,20 +64,20 @@ const CippContactPermissionsDialog = ({ formHook, combinedOptions, isUserGroupLo name="Permissions" creatable={false} validators={{ - validate: (value) => (value ? true : "Select the permission level for the contact"), + validate: (value) => (value ? true : 'Select the permission level for the contact'), }} options={[ - { value: "Author", label: "Author" }, - { value: "Contributor", label: "Contributor" }, - { value: "Editor", label: "Editor" }, - { value: "Owner", label: "Owner" }, - { value: "NonEditingAuthor", label: "Non Editing Author" }, - { value: "PublishingAuthor", label: "Publishing Author" }, - { value: "PublishingEditor", label: "Publishing Editor" }, - { value: "Reviewer", label: "Reviewer" }, - { value: "LimitedDetails", label: "Limited Details" }, - { value: "AvailabilityOnly", label: "Availability Only" }, - { value: "None", label: "None" }, + { value: 'Author', label: 'Author' }, + { value: 'Contributor', label: 'Contributor' }, + { value: 'Editor', label: 'Editor' }, + { value: 'Owner', label: 'Owner' }, + { value: 'NonEditingAuthor', label: 'Non Editing Author' }, + { value: 'PublishingAuthor', label: 'Publishing Author' }, + { value: 'PublishingEditor', label: 'Publishing Editor' }, + { value: 'Reviewer', label: 'Reviewer' }, + { value: 'LimitedDetails', label: 'Limited Details' }, + { value: 'AvailabilityOnly', label: 'Availability Only' }, + { value: 'None', label: 'None' }, ]} multiple={false} formControl={formHook} @@ -65,8 +88,8 @@ const CippContactPermissionsDialog = ({ formHook, combinedOptions, isUserGroupLo
    - ); -}; + ) +} -export default CippContactPermissionsDialog; +export default CippContactPermissionsDialog diff --git a/src/components/CippComponents/CippCoverageHeatmap.jsx b/src/components/CippComponents/CippCoverageHeatmap.jsx new file mode 100644 index 000000000000..830ddde36e5c --- /dev/null +++ b/src/components/CippComponents/CippCoverageHeatmap.jsx @@ -0,0 +1,148 @@ +import { useEffect, useMemo, useState } from "react"; +import { Box, TablePagination, Typography } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; +import { windowColumns, tenantWindowGrid, toMs, STATE_LABELS } from "../../utils/cipp-coverage"; + +// Per-tenant coverage heatmap: rows = tenants, columns = individual coverage windows (not bucketed), +// cell colour = state. Empty interior cells are coverage gaps. Tenant rows are paginated (MUI), and +// cells use native title tooltips + click-to-filter. +export const CippCoverageHeatmap = ({ rows = [], onCellClick }) => { + const theme = useTheme(); + const columns = useMemo(() => windowColumns(rows), [rows]); + const grid = useMemo(() => tenantWindowGrid(rows, columns), [rows, columns]); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(25); + + useEffect(() => { + setPage(0); + }, [rowsPerPage, grid.length]); + + if (!columns.length || !grid.length) { + return ( + + No coverage data in this period. + + ); + } + + const colors = { + Processed: theme.palette.success.main, + InFlight: theme.palette.info?.main || theme.palette.primary.main, + Retrying: theme.palette.warning.main, + DeadLetter: theme.palette.error.main, + Skipped: theme.palette.mode === "dark" ? theme.palette.grey[700] : theme.palette.grey[400], + }; + + const fmtFull = (ms) => + new Date(ms).toLocaleString([], { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }); + const fmtTime = (ms) => new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + + const headerStep = Math.max(1, Math.ceil(columns.length / 10)); + const colTemplate = `minmax(110px, 160px) repeat(${columns.length}, minmax(8px, 1fr))`; + const innerMinWidth = columns.length > 40 ? columns.length * 14 + 170 : "auto"; + + const pageRows = + rowsPerPage === -1 ? grid : grid.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage); + + const tip = (tenant, colIdx, state, cellRows) => { + const start = columns[colIdx]; + const end = cellRows.length ? toMs(cellRows[0].WindowEnd) : null; + const range = end ? `${fmtFull(start)} - ${fmtFull(end)}` : fmtFull(start); + const lines = [tenant, range, `Status: ${STATE_LABELS[state] || state}`]; + if (cellRows.length) { + const searchStatus = cellRows.map((r) => r.SearchStatus).filter(Boolean).slice(-1)[0]; + if (searchStatus && (state === "InFlight" || state === "Retrying")) lines.push(`search: ${searchStatus}`); + const recs = cellRows.reduce((a, r) => a + (Number(r.RecordCount) || 0), 0); + const matched = cellRows.reduce((a, r) => a + (Number(r.MatchedCount) || 0), 0); + const retries = cellRows.reduce((a, r) => a + (Number(r.RetryCount) || 0), 0); + const throttles = cellRows.reduce((a, r) => a + (Number(r.ThrottleCount) || 0), 0); + const lastErr = cellRows.map((r) => r.LastError).filter(Boolean).slice(-1)[0]; + lines.push(`${recs} records${matched ? ` - ${matched} matched` : ""}`); + if (retries || throttles) lines.push(`retries ${retries} - throttles ${throttles}`); + if (lastErr) lines.push(lastErr); + } else if (state === "Gap") { + lines.push("No window created (coverage gap)"); + } + return lines.join("\n"); + }; + + return ( + + + + + + {columns.map((ms, i) => ( + + {i % headerStep === 0 ? fmtTime(ms) : ""} + + ))} + + {pageRows.map((t) => ( + + + {t.name.replace(/\.onmicrosoft\.com$/, "")} + + {t.states.map((s, i) => { + const empty = s === "Empty"; + const gap = s === "Gap"; + return ( + + !empty && + onCellClick && + onCellClick({ + tenant: t.name, + startMs: columns[i], + endMs: t.cells[i].length ? toMs(t.cells[i][0].WindowEnd) : null, + }) + } + sx={{ + height: 20, + borderRadius: "3px", + cursor: empty ? "default" : "pointer", + bgcolor: gap || empty ? "transparent" : colors[s], + border: gap + ? `1px dashed ${theme.palette.error.main}` + : empty + ? `1px solid ${theme.palette.divider}` + : "none", + opacity: empty ? 0.35 : 1, + transition: "transform .1s ease-in-out", + "&:hover": empty ? {} : { transform: "scale(1.18)", zIndex: 1 }, + }} + /> + ); + })} + + ))} + + + setPage(p)} + rowsPerPage={rowsPerPage} + onRowsPerPageChange={(e) => { + setRowsPerPage(parseInt(e.target.value, 10)); + setPage(0); + }} + rowsPerPageOptions={[25, 50, 100, 250, { label: "All", value: -1 }]} + labelRowsPerPage="Tenants per page" + /> + + ); +}; + +export default CippCoverageHeatmap; diff --git a/src/components/CippComponents/CippCredentialExpandList.jsx b/src/components/CippComponents/CippCredentialExpandList.jsx index b86c4e1713c7..f1a36381f123 100644 --- a/src/components/CippComponents/CippCredentialExpandList.jsx +++ b/src/components/CippComponents/CippCredentialExpandList.jsx @@ -2,6 +2,7 @@ import { useCallback, useState } from "react"; import PropTypes from "prop-types"; import { Box, + Button, IconButton, List, ListItem, @@ -10,6 +11,7 @@ import { MenuItem, Typography, } from "@mui/material"; +import AddIcon from "@mui/icons-material/Add"; import MoreVertIcon from "@mui/icons-material/MoreVert"; import { useDispatch } from "react-redux"; import axios from "axios"; @@ -17,6 +19,9 @@ import { buildVersionedHeaders } from "../../utils/cippVersion"; import { showToast } from "../../store/toasts"; import { getCippError } from "../../utils/get-cipp-error"; import { ConfirmationDialog } from "../confirmation-dialog"; +import { CippApiDialog } from "./CippApiDialog"; +import { useDialog } from "../../hooks/use-dialog"; +import { ADD_CLIENT_SECRET_FIELDS } from "./AppRegistrationActions.jsx"; const credentialPrimaryLabel = (cred, credentialType) => { if (credentialType === "password") { @@ -40,6 +45,8 @@ export const CippCredentialExpandList = ({ tenantFilter, canRemove, onRemoved, + canAdd = false, + onAdded, }) => { const dispatch = useDispatch(); const [menuAnchor, setMenuAnchor] = useState(null); @@ -48,6 +55,12 @@ export const CippCredentialExpandList = ({ const [removalConfirmOpen, setRemovalConfirmOpen] = useState(false); const [credPendingRemoval, setCredPendingRemoval] = useState(null); const [removalSubmitting, setRemovalSubmitting] = useState(false); + const addDialog = useDialog(); + const rotateDialog = useDialog(); + const [rotateCred, setRotateCred] = useState(null); + + const canAddSecret = canAdd && credentialType === "password"; + const canRotate = canRemove && credentialType === "password"; const closeMenu = useCallback(() => { setMenuAnchor(null); @@ -140,18 +153,91 @@ export const CippCredentialExpandList = ({ setCredPendingRemoval(null); }, [removalSubmitting]); + const openRotate = useCallback(() => { + if (!menuCred) { + return; + } + setRotateCred(menuCred); + closeMenu(); + rotateDialog.handleOpen(); + }, [menuCred, closeMenu, rotateDialog]); + + const addSecretButton = canAddSecret ? ( + + + + ) : 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 c03c976f9f94..7cfc18fc1ee9 100644 --- a/src/components/CippComponents/CippDeployCompliancePolicyDrawer.jsx +++ b/src/components/CippComponents/CippDeployCompliancePolicyDrawer.jsx @@ -61,16 +61,24 @@ 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", "EncryptionEnabled": true, - "EncryptionProtectionType": "Template", - "ContentMarkingHeaderEnabled": true, - "ContentMarkingHeaderText": "Confidential - Internal Use Only", + "EncryptionProtectionType": "UserDefined", + "EncryptionPromptUser": true, + "EncryptionDoNotForward": true, "PolicyParams": { "Name": "Confidential Label Policy", "ExchangeLocation": "All", @@ -107,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, @@ -123,6 +147,7 @@ export const CippDeployCompliancePolicyDrawer = ({ selectedTenants: [], TemplateList: null, PowerShellCommand: "", + labelColor: "", }, }); @@ -133,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, @@ -147,6 +175,7 @@ export const CippDeployCompliancePolicyDrawer = ({ selectedTenants: [], TemplateList: null, PowerShellCommand: "", + labelColor: "", }); } }, [deployPolicy.isSuccess, formControl]); @@ -158,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, }); }; @@ -170,6 +211,7 @@ export const CippDeployCompliancePolicyDrawer = ({ selectedTenants: [], TemplateList: null, PowerShellCommand: "", + labelColor: "", }); }; @@ -231,7 +273,7 @@ export const CippDeployCompliancePolicyDrawer = ({ multiple={false} api={{ queryKey: config.templateQueryKey, - labelField: "name", + labelField: config.templateLabelField ?? "name", valueField: (option) => option, url: config.listTemplatesUrl, }} @@ -241,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 628a9aa0dafc..51f32df9a45c 100644 --- a/src/components/CippComponents/CippFormComponent.jsx +++ b/src/components/CippComponents/CippFormComponent.jsx @@ -25,6 +25,25 @@ import { CippDataTable } from "../CippTable/CippDataTable"; import React from "react"; import { CloudUpload } from "@mui/icons-material"; import { Stack } from "@mui/system"; +import countryList from "../../data/countryList"; +import languageList from "../../data/languageList"; + +// ISO 3166-1 alpha-2 country/region codes (uppercase), used by the CountryCodeMultiSelect type. +const countryCodeOptions = countryList + .map((c) => ({ label: `${c.Name} (${c.Code})`, value: c.Code })) + .sort((a, b) => a.label.localeCompare(b.label)); + +// ISO 639-1 alpha-2 language codes (lowercase), used by the LanguageCodeMultiSelect type. +// Derived from the locale tags in languageList.json, deduplicated to the two-letter primary subtag (e.g. "en-US" -> "en"). +const languageCodeOptions = Object.values( + languageList.reduce((acc, entry) => { + const code = entry.tag?.split("-")[0]?.toLowerCase(); + if (code && code.length === 2 && !acc[code]) { + acc[code] = { label: `${entry.language} (${code})`, value: code }; + } + return acc; + }, {}), +).sort((a, b) => a.label.localeCompare(b.label)); // The tiptap / prosemirror / mui-tiptap editor tree is large and only used by `richText` fields. // Load it on demand via next/dynamic so it is code-split into an async chunk instead of being @@ -87,6 +106,59 @@ export const CippFormComponent = (props) => { } }; + // Shared renderer for autoComplete-backed fields (autoComplete + the ISO-code multiselects). + const renderAutoCompleteField = (autoCompleteProps) => { + // Resolve options if it's a function + const resolvedOptions = + typeof autoCompleteProps.options === "function" + ? autoCompleteProps.options(row) + : autoCompleteProps.options; + + // Wrap validate function to pass row as third parameter + const resolvedValidators = validators + ? { + ...validators, + validate: + typeof validators.validate === "function" + ? (value, formValues) => validators.validate(value, formValues, row) + : validators.validate, + } + : validators; + + return ( +
    + ( + field.onChange(value)} + onBlur={field.onBlur} + /> + )} + /> + + {get(errors, convertedName, {})?.message && ( + + {get(errors, convertedName, {})?.message} + + )} + {helperText && ( + + {helperText} + + )} +
    + ); + }; + switch (type) { case "heading": return ( @@ -194,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 ( <> @@ -373,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 ( { ); - case "autoComplete": { - // Resolve options if it's a function - const resolvedOptions = - typeof other.options === "function" ? other.options(row) : other.options; - - // Wrap validate function to pass row as third parameter - const resolvedValidators = validators - ? { - ...validators, - validate: - typeof validators.validate === "function" - ? (value, formValues) => validators.validate(value, formValues, row) - : validators.validate, - } - : validators; + case "autoComplete": + return renderAutoCompleteField(other); - return ( -
    - ( - field.onChange(value)} - onBlur={field.onBlur} - /> - )} - /> + // ISO 3166-1 alpha-2 region/country code multiselect (e.g. Spam Filter RegionBlockList). + case "CountryCodeMultiSelect": + return renderAutoCompleteField({ + ...other, + options: countryCodeOptions, + multiple: true, + creatable: false, + }); - {get(errors, convertedName, {})?.message && ( - - {get(errors, convertedName, {})?.message} - - )} - {helperText && ( - - {helperText} - - )} -
    - ); - } + // ISO 639-1 alpha-2 language code multiselect (e.g. Spam Filter LanguageBlockList). + case "LanguageCodeMultiSelect": + return renderAutoCompleteField({ + ...other, + options: languageCodeOptions, + multiple: true, + creatable: false, + }); case "richText": { return ( diff --git a/src/components/CippComponents/CippFormUserAndGroupSelector.jsx b/src/components/CippComponents/CippFormUserAndGroupSelector.jsx index b2fef52aa1a5..ddb0992eeab8 100644 --- a/src/components/CippComponents/CippFormUserAndGroupSelector.jsx +++ b/src/components/CippComponents/CippFormUserAndGroupSelector.jsx @@ -30,12 +30,13 @@ export const CippFormUserAndGroupSelector = ({ url: "/api/ListUsersAndGroups", dataKey: "Results", labelField: (option) => { - // If it's a group (no userPrincipalName), just show displayName - if (!option.userPrincipalName) { - return `${option.displayName}`; - } - // If it's a user, show displayName and userPrincipalName - return `${option.displayName} (${option.userPrincipalName})`; + if (option.userPrincipalName) return `${option.displayName} (${option.userPrincipalName})`; + const groupType = option.mailEnabled && !option.securityEnabled + ? "Distribution Group" + : option.mailEnabled && option.securityEnabled + ? "Mail-Enabled Security Group" + : "Security Group"; + return `${option.displayName} (${groupType})`; }, valueField: valueField ? valueField : "id", queryKey: `ListUsersAndGroups-${ @@ -52,13 +53,6 @@ export const CippFormUserAndGroupSelector = ({ }, showRefresh: showRefresh, }} - groupBy={(option) => { - // Group by type - Users or Groups - if (option["@odata.type"] === "#microsoft.graph.group") { - return "Groups"; - } - return "Users"; - }} creatable={false} {...other} /> diff --git a/src/components/CippComponents/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..3544f6cdea93 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', + defaultValue: 'append', + // 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 || 'append') === '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) => { @@ -89,8 +179,9 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) => type: item?.URLName || policyType, ...(platformType && { platformType }), AssignTo: assignTo, - assignmentMode: formData?.assignmentMode || 'replace', - excludeGroup: formData?.excludeGroup || null, + assignmentMode: formData?.assignmentMode || 'append', + 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), - assignmentMode: formData?.assignmentMode || 'replace', - excludeGroup: formData?.excludeGroup || null, + GroupIds: isExclude ? [] : ids, + GroupNames: isExclude ? [] : names, + ExcludeGroupIds: isExclude ? ids : [], + ExcludeGroupNames: isExclude ? names : [], + assignmentDirection: formData?.assignmentDirection || 'include', + assignmentMode: formData?.assignmentMode || 'append', 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/CippLibraryPermissionsDialog.jsx b/src/components/CippComponents/CippLibraryPermissionsDialog.jsx new file mode 100644 index 000000000000..fd3eb5bdc74c --- /dev/null +++ b/src/components/CippComponents/CippLibraryPermissionsDialog.jsx @@ -0,0 +1,433 @@ +import { useMemo } from 'react' +import { + Alert, + AlertTitle, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Stack, + Typography, +} from '@mui/material' +import { Add, Delete, LinkOff, Link as LinkIcon, Tune } from '@mui/icons-material' +import { useForm } from 'react-hook-form' +import { CippDataTable } from '../CippTable/CippDataTable' +import { CippApiDialog } from './CippApiDialog' +import CippFormComponent from './CippFormComponent' +import { ApiGetCall } from '../../api/ApiCall' +import { useDialog } from '../../hooks/use-dialog' +import { usePermissions } from '../../hooks/use-permissions' + +// Sentinel for "the site itself" in the scope picker. A site collection root web always holds its +// own permissions, so it has no inheritance to manage - only assignments. +const SITE_ROOT = '__siteRoot__' +const SITE_ROOT_OPTION = { label: 'Site root (whole site)', value: SITE_ROOT } + +// Stable empty fallback: CippDataTable syncs its `data` prop by reference, so handing it a fresh +// [] on every render would re-trigger that sync in a loop. +const NO_ASSIGNMENTS = [] + +// Unwraps the { label, value } shape the select controls persist. +const optionValue = (x) => (x && typeof x === 'object' && 'value' in x ? x.value : x) + +// Custom-component action dialog: shows the permissions of a site's document library (or of the +// site root), and lets them be added, changed, removed, and detached from or reattached to the +// site's own permissions. +export const CippLibraryPermissionsDialog = ({ + row, + tenantFilter, + drawerVisible, + setDrawerVisible, +}) => { + const siteRow = Array.isArray(row) ? row[0] : row + const siteUrl = siteRow?.webUrl + const siteId = siteRow?.siteId + const tenant = siteRow?.Tenant ?? tenantFilter + const { checkPermissions } = usePermissions() + const canWrite = checkPermissions(['Sharepoint.Site.ReadWrite']) + + const addDialog = useDialog() + const breakDialog = useDialog() + const resetDialog = useDialog() + + const scopeForm = useForm({ defaultValues: { scope: SITE_ROOT_OPTION } }) + const selectedScope = scopeForm.watch('scope') + const selectedScopeId = optionValue(selectedScope) + const isSiteRoot = !selectedScopeId || selectedScopeId === SITE_ROOT + // The backend treats an empty ListId as "the site root", so it is safe to send either way. + const listId = isSiteRoot ? '' : selectedScopeId + const libraryName = isSiteRoot ? '' : (selectedScope?.label ?? '') + const scopeLabel = isSiteRoot ? 'the site root' : `the ${libraryName} library` + + const isOpen = !!drawerVisible + const permissionsQueryKey = `SitePermissions-${siteUrl}-${listId || 'root'}` + // CippDataTable always spins up an infinite query on the queryKey it is given, even with no api + // url. Sharing our key would point that infinite query at this plain query's cached object and + // it would blow up looking for data.pages, so the table gets a key of its own. + const tableQueryKey = `${permissionsQueryKey}-table` + + const libraries = ApiGetCall({ + url: '/api/ListSiteLibraries', + data: { SiteId: siteId, SiteUrl: siteUrl, tenantFilter: tenant }, + queryKey: `SiteLibraries-${siteId ?? siteUrl}`, + waiting: isOpen && !!siteUrl, + }) + + const permissions = ApiGetCall({ + url: '/api/ListSitePermissions', + data: { SiteUrl: siteUrl, ListId: listId, tenantFilter: tenant }, + queryKey: permissionsQueryKey, + waiting: isOpen && !!siteUrl, + }) + + const roleDefinitions = ApiGetCall({ + url: '/api/ListSiteRoleDefinitions', + data: { SiteUrl: siteUrl, tenantFilter: tenant }, + queryKey: `SiteRoleDefinitions-${siteUrl}`, + waiting: isOpen && !!siteUrl, + }) + + const scopeOptions = useMemo(() => { + const libs = Array.isArray(libraries.data?.Results) ? libraries.data.Results : [] + return [ + SITE_ROOT_OPTION, + ...libs.map((library) => ({ label: library.Title, value: library.Id })), + ] + }, [libraries.data]) + + const levelOptions = useMemo(() => { + const definitions = Array.isArray(roleDefinitions.data?.Results) + ? roleDefinitions.data.Results + : [] + return definitions.map((definition) => ({ + label: definition.IsCustom ? `${definition.Name} (custom)` : definition.Name, + value: definition.Id, + })) + }, [roleDefinitions.data]) + + // The endpoint returns an error string in Results instead of the permission object. + const result = permissions.data?.Results + const permissionData = typeof result === 'object' && result !== null ? result : null + const loadError = typeof result === 'string' ? result : null + const assignments = permissionData?.Assignments ?? NO_ASSIGNMENTS + const hasUnique = permissionData?.HasUniqueRoleAssignments === true + + // Payload shared by every action on the current scope. + const scopePayload = { + tenantFilter: tenant, + SiteUrl: siteUrl, + ListId: listId, + LibraryName: libraryName, + } + + // Changing permissions requires the library to hold its own set, so an inheriting library is + // detached first. Say so up front rather than surprising the admin with it afterwards. + const inheritanceWarning = + !isSiteRoot && !hasUnique + ? ` ${libraryName} currently inherits its permissions from the site, so this will stop it inheriting and copy the current permissions across first.` + : '' + + const actions = [ + { + label: 'Change Permission Level', + type: 'POST', + icon: , + url: '/api/ExecSetLibraryPermission', + confirmText: `Set the permission level [Title] holds on ${scopeLabel}. Any other level they hold here is removed.${inheritanceWarning}`, + condition: (assignment) => canWrite && !assignment.IsSystemManaged, + relatedQueryKeys: [permissionsQueryKey], + children: ({ formHook }) => ( + + ), + customDataformatter: (actionRow, action, formData) => { + const assignment = Array.isArray(actionRow) ? actionRow[0] : actionRow + return { + ...scopePayload, + PrincipalId: assignment.PrincipalId, + PrincipalName: assignment.Title, + RoleDefinitionId: optionValue(formData.RoleDefinitionId), + Mode: 'Replace', + } + }, + multiPost: false, + allowResubmit: true, + }, + { + label: 'Remove Permission', + type: 'POST', + icon: , + url: '/api/ExecRemoveLibraryPermission', + confirmText: `Remove [PermissionLevel] from [Title] on ${scopeLabel}?${inheritanceWarning}`, + color: 'error', + condition: (assignment) => canWrite && !assignment.IsSystemManaged, + relatedQueryKeys: [permissionsQueryKey], + customDataformatter: (actionRow) => { + const assignment = Array.isArray(actionRow) ? actionRow[0] : actionRow + return { + ...scopePayload, + PrincipalId: assignment.PrincipalId, + PrincipalName: assignment.Title, + RoleDefinitionId: assignment.RoleDefinitionId, + } + }, + multiPost: false, + }, + ] + + return ( + setDrawerVisible(false)}> + + Permissions{siteRow?.displayName ? ` — ${siteRow.displayName}` : ''} + + + + + + {loadError && {loadError}} + + {!loadError && !isSiteRoot && !permissions.isFetching && permissionData && ( + + + {hasUnique ? 'Unique permissions' : 'Inheriting from the site'} + + {hasUnique + ? `${libraryName} has its own permissions and no longer follows the site. Restoring inheritance discards the permissions listed below.` + : `${libraryName} follows the site's permissions. Granting or changing a permission here stops it inheriting and copies the current permissions across.`} + + {hasUnique ? ( + + ) : ( + + )} + + + )} + + {isSiteRoot && ( + + These are the permissions on the site itself. Every library that still inherits gets + its permissions from here. + + )} + + + + + + + + + + + + + ({ + ...scopePayload, + RoleDefinitionId: optionValue(formData.RoleDefinitionId), + Users: formData.Users ?? [], + Groups: formData.Groups ?? [], + Mode: 'Add', + }), + multiPost: false, + }} + row={siteRow ?? {}} + > + {({ formHook }) => ( + <> + `${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, + }} + /> + + + )} + + + ({ + ...scopePayload, + Action: 'Break', + CopyRoleAssignments: formData.CopyRoleAssignments !== false, + ClearSubscopes: formData.ClearSubscopes === true, + }), + multiPost: false, + }} + row={siteRow ?? {}} + defaultvalues={{ CopyRoleAssignments: true, ClearSubscopes: false }} + > + {({ formHook }) => ( + <> + + + Turn this off to start from an empty permission set. Nobody but site collection admins + will reach the library until permissions are granted. + + + + )} + + + ({ ...scopePayload, Action: 'Reset' }), + multiPost: false, + }} + row={siteRow ?? {}} + /> + + ) +} + +export default CippLibraryPermissionsDialog 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/CippMultiQueueTracker.jsx b/src/components/CippComponents/CippMultiQueueTracker.jsx new file mode 100644 index 000000000000..4be060760ed3 --- /dev/null +++ b/src/components/CippComponents/CippMultiQueueTracker.jsx @@ -0,0 +1,195 @@ +import { useEffect, useState } from 'react' +import { + Badge, + Box, + Chip, + IconButton, + LinearProgress, + Stack, + Tooltip, + Typography, +} from '@mui/material' +import { Circle, Timeline } from '@mui/icons-material' +import { useQueryClient } from '@tanstack/react-query' +import { CippOffCanvas } from './CippOffCanvas' +import { ApiGetCall } from '../../api/ApiCall' + +// Terminal states, i.e. nothing more will happen to this queue. +const isFinished = (status) => + ['Completed', 'Failed', 'Completed (with errors)', 'Not found'].includes(status) + +const statusColour = (status) => { + if (status === 'Completed') return 'success.main' + if (status === 'Completed (with errors)') return 'warning.main' + if (status === 'Failed') return 'error.main' + return 'warning.main' +} + +/** + * Progress indicator for an action that starts several background queues at once, such as a + * report sync that refreshes more than one cache. CippQueueTracker covers the single-queue case + * where the id arrives on the table's own list response; this one takes the ids the action + * returned and rolls them up through ListCippQueues. + * + * Polling stops as soon as every queue reaches a terminal state, and the supplied query keys are + * invalidated once, so the page showing the data refreshes itself rather than leaving the admin + * looking at pre-sync numbers. + */ +export const CippMultiQueueTracker = ({ queueIds = [], relatedQueryKeys = [], label = 'Sync' }) => { + const queryClient = useQueryClient() + const [canvasVisible, setCanvasVisible] = useState(false) + const [hasInvalidated, setHasInvalidated] = useState(false) + + const ids = Array.isArray(queueIds) ? queueIds.filter(Boolean) : [] + const idKey = ids.join(',') + + const polling = ApiGetCall({ + url: '/api/ListCippQueues', + data: { QueueIds: idKey }, + queryKey: `CippQueues-${idKey || 'none'}`, + waiting: ids.length > 0, + refetchInterval: (data) => (isFinished(data?.Summary?.Status) ? false : 3000), + refetchOnWindowFocus: false, + staleTime: 0, + }) + + const summary = polling.data?.Summary + const queues = polling.data?.Queues ?? [] + const finished = isFinished(summary?.Status) + + // Refresh the data the sync was run for, once, when everything has finished. + useEffect(() => { + if (!finished || hasInvalidated || ids.length === 0) return + relatedQueryKeys.filter(Boolean).forEach((key) => { + queryClient.invalidateQueries({ queryKey: [key] }) + }) + setHasInvalidated(true) + }, [finished, hasInvalidated, ids.length, relatedQueryKeys, queryClient]) + + // A new set of queues means a new run: allow the refresh to fire again. + useEffect(() => { + setHasInvalidated(false) + }, [idKey]) + + if (ids.length === 0) return null + + const percent = summary?.PercentComplete ?? 0 + const tooltip = finished + ? `${label} ${summary?.Status?.toLowerCase() ?? 'finished'} — ${summary?.CompletedTasks ?? 0}/${summary?.TotalTasks ?? 0} tasks` + : `${label} running — ${percent}% (${summary?.CompletedTasks ?? 0}/${summary?.TotalTasks ?? 0} tasks across ${summary?.FoundQueues ?? ids.length} caches)` + + return ( + <> + + } + overlap="circular" + anchorOrigin={{ vertical: 'top', horizontal: 'right' }} + > + setCanvasVisible(true)} + sx={{ + color: finished ? statusColour(summary?.Status) : 'primary.main', + animation: finished ? 'none' : 'pulse 2s infinite', + '@keyframes pulse': { + '0%': { transform: 'scale(1)', opacity: 1 }, + '50%': { transform: 'scale(1.1)', opacity: 0.8 }, + '100%': { transform: 'scale(1)', opacity: 1 }, + }, + }} + > + + + + + + setCanvasVisible(false)} + > + + + + {summary?.Status ?? 'Starting'} — {percent}% complete + + + + + + + Caches: {summary?.FoundQueues ?? 0} of {summary?.TotalQueues ?? 0} + + + Tasks: {summary?.CompletedTasks ?? 0} / {summary?.TotalTasks ?? 0} + + + Running: {summary?.RunningTasks ?? 0} + + + Failed: {summary?.FailedTasks ?? 0} + + + + Per cache + + {queues.map((queue) => ( + + + + {queue.Name} + + + + + {queue.CompletedTasks ?? 0} of {queue.TotalTasks ?? 0} tasks + {queue.FailedTasks > 0 ? ` — ${queue.FailedTasks} failed` : ''} + + + + ))} + {queues.length === 0 && ( + + {polling.isFetching ? 'Loading queue status…' : 'No queue data available yet.'} + + )} + + + {summary?.TotalQueues > summary?.FoundQueues && ( + + {summary.TotalQueues - summary.FoundQueues} queue(s) could not be found. They may have + finished and aged out of the queue history, or failed to start. + + )} + + + + ) +} + +export default CippMultiQueueTracker diff --git a/src/components/CippComponents/CippNotificationForm.jsx b/src/components/CippComponents/CippNotificationForm.jsx index f2f74b19d7f1..03ecce096ec9 100644 --- a/src/components/CippComponents/CippNotificationForm.jsx +++ b/src/components/CippComponents/CippNotificationForm.jsx @@ -42,7 +42,6 @@ export const CippNotificationForm = ({ { label: "Adding a group", value: "AddGroup" }, { label: "Adding a tenant", value: "NewTenant" }, { label: "Executing the offboard wizard", value: "ExecOffboardUser" }, - { label: "Custom Test Alerts", value: "CustomTests" }, ]; const severityTypes = [ diff --git a/src/components/CippComponents/CippOffboardingDefaultSettings.jsx b/src/components/CippComponents/CippOffboardingDefaultSettings.jsx index 5b6189ad2a7c..34fefd8ab509 100644 --- a/src/components/CippComponents/CippOffboardingDefaultSettings.jsx +++ b/src/components/CippComponents/CippOffboardingDefaultSettings.jsx @@ -1,49 +1,43 @@ import { CippPropertyListCard } from '../../components/CippCards/CippPropertyListCard' import CippFormComponent from '../../components/CippComponents/CippFormComponent' -import { Typography, Box } from '@mui/material' +import { Box, Chip, Typography } from '@mui/material' +import { Grid } from '@mui/system' export const CippOffboardingDefaultSettings = (props) => { const { formControl, defaultsSource = null, title = 'Offboarding Default Settings' } = props - const getSourceIndicator = () => { - // Only show the indicator if defaultsSource is explicitly provided (for wizard, not tenant config) - if (!defaultsSource || defaultsSource === null) return null + const getSourceChip = () => { + // Only show the chip if defaultsSource is explicitly provided (for wizard/preferences, not tenant config) + if (!defaultsSource) return null - let sourceText = '' - let color = 'text.secondary' - - switch (defaultsSource) { - case 'tenant': - sourceText = 'Using Tenant Defaults' - color = 'primary.main' - break - case 'user': - sourceText = 'Using User Defaults' - color = 'info.main' - break - case 'none': - default: - sourceText = 'Using Default Settings' - color = 'text.secondary' - break + const sourceConfig = { + tenant: { label: 'Using Tenant Defaults', color: 'primary' }, + user: { label: 'Using User Defaults', color: 'info' }, + allUsers: { label: 'Using All Users Defaults', color: 'default' }, + none: { label: 'Using Default Settings', color: 'default' }, } - return ( - - - {sourceText} - - - ) + const { label, color } = sourceConfig[defaultsSource] ?? sourceConfig.none + + return } + const sourceChip = getSourceChip() + const cardTitle = sourceChip ? ( + + {title} + {sourceChip} + + ) : ( + title + ) + return ( <> - {getSourceIndicator()} { ), }, ]} + cardButton={ + + + Send results to + + + + + + + + + + + + + + } /> ) diff --git a/src/components/CippComponents/CippPolicyDeployDrawer.jsx b/src/components/CippComponents/CippPolicyDeployDrawer.jsx index ad695d39cace..6c0eab9bc7b3 100644 --- a/src/components/CippComponents/CippPolicyDeployDrawer.jsx +++ b/src/components/CippComponents/CippPolicyDeployDrawer.jsx @@ -17,6 +17,38 @@ const assignmentFilterTypeOptions = [ { label: 'Exclude - Apply policy to devices NOT matching filter', value: 'exclude' }, ] +// Reserved replacement variables handled server-side by Get-CIPPTextReplacement. +// These are populated automatically per tenant, so they must never be prompted for here. +// Stored without the surrounding %% and lowercased for case-insensitive matching, since +// templates may reference them in any casing (e.g. %TenantId%, %tenantid%). +const reservedReplacementVariables = new Set( + [ + 'serial', + 'systemroot', + 'systemdrive', + 'system32', + 'osdrive', + 'temp', + 'tenantid', + 'tenantfilter', + 'initialdomain', + 'tenantname', + 'partnertenantid', + 'samappid', + 'userprofile', + 'username', + 'userdomain', + 'windir', + 'programfiles', + 'programfiles(x86)', + 'programdata', + 'cippuserschema', + 'cippurl', + 'defaultdomain', + 'organizationid', + ].map((variable) => variable.toLowerCase()), +) + export const CippPolicyDeployDrawer = ({ buttonText = 'Deploy Policy', requiredPermissions = [], @@ -259,7 +291,9 @@ export const CippPolicyDeployDrawer = ({ {(() => { const rawJson = jsonWatch ? jsonWatch : '' const placeholderMatches = [...rawJson.matchAll(/%(\w+)%/g)].map((m) => m[1]) - const uniquePlaceholders = Array.from(new Set(placeholderMatches)) + const uniquePlaceholders = Array.from(new Set(placeholderMatches)).filter( + (placeholder) => !reservedReplacementVariables.has(placeholder.toLowerCase()), + ) if (uniquePlaceholders.length === 0 || selectedTenants.length === 0) { return null } diff --git a/src/components/CippComponents/CippPolicyImportDrawer.jsx b/src/components/CippComponents/CippPolicyImportDrawer.jsx index 7c1630ce2eb4..ca9f5cf4bf40 100644 --- a/src/components/CippComponents/CippPolicyImportDrawer.jsx +++ b/src/components/CippComponents/CippPolicyImportDrawer.jsx @@ -47,7 +47,7 @@ export const CippPolicyImportDrawer = ({ const tenantPolicies = ApiGetCall({ url: mode === 'ConditionalAccess' - ? `/api/ListCATemplates?TenantFilter=${tenantFilter?.value || ''}` + ? `/api/ListConditionalAccessPolicies?TenantFilter=${tenantFilter?.value || ''}` : mode === 'Standards' ? `/api/listStandardTemplates?TenantFilter=${tenantFilter?.value || ''}` : `/api/ListIntunePolicy?type=ESP&TenantFilter=${tenantFilter?.value || ''}`, @@ -110,12 +110,14 @@ export const CippPolicyImportDrawer = ({ // For Conditional Access, convert RawJSON to object and send the contents let policyData = policy - // If the policy has RawJSON, parse it and use that as the data - if (policy.RawJSON) { + // If the policy has rawjson, parse it and use that as the data. + // ListConditionalAccessPolicies returns the raw Graph policy as lowercase `rawjson`. + const rawJson = policy.rawjson ?? policy.RawJSON + if (rawJson) { try { - policyData = JSON.parse(policy.RawJSON) + policyData = JSON.parse(rawJson) } catch (e) { - console.error('Failed to parse RawJSON:', e) + console.error('Failed to parse rawjson:', e) policyData = policy } } @@ -187,8 +189,19 @@ export const CippPolicyImportDrawer = ({ }, }) } else { - // For tenant policies, use the policy object directly - setViewingPolicy(policy || {}) + // For tenant policies, show the raw policy JSON when available + // (ConditionalAccess returns the Graph policy as lowercase `rawjson`). + const rawJson = policy?.rawjson ?? policy?.RawJSON + if (mode === 'ConditionalAccess' && rawJson) { + try { + setViewingPolicy(JSON.parse(rawJson)) + } catch (e) { + console.error('Failed to parse rawjson for view:', e) + setViewingPolicy(policy || {}) + } + } else { + setViewingPolicy(policy || {}) + } } setViewDialogOpen(true) } catch (error) { diff --git a/src/components/CippComponents/CippQueryRefreshButton.jsx b/src/components/CippComponents/CippQueryRefreshButton.jsx new file mode 100644 index 000000000000..313a07c607e5 --- /dev/null +++ b/src/components/CippComponents/CippQueryRefreshButton.jsx @@ -0,0 +1,48 @@ +import { IconButton, SvgIcon, Tooltip } from '@mui/material' +import { ArrowPathIcon } from '@heroicons/react/24/outline' +import { useQueryClient } from '@tanstack/react-query' + +/** + * Reloads a page's cached data on demand by invalidating its query keys. + * + * Distinct from a Sync button: syncing re-reads the tenant from Microsoft and queues background + * work that can take a long time, whereas this only re-reads what CIPP already has stored. Use it + * after a sync finishes elsewhere, or when someone else has refreshed the cache and the page is + * showing what it loaded on arrival. + */ +export const CippQueryRefreshButton = ({ + queryKeys = [], + isFetching = false, + tooltip = 'Reload the cached data. This does not re-scan the tenant — use Sync data for that.', +}) => { + const queryClient = useQueryClient() + + const handleRefresh = () => { + queryKeys.filter(Boolean).forEach((key) => { + queryClient.invalidateQueries({ queryKey: [key] }) + }) + } + + return ( + + + + + + + + + + ) +} + +export default CippQueryRefreshButton 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, + postExecution: { + psa: formValues.offboardingDefaults?.postExecution?.psa, + email: formValues.offboardingDefaults?.postExecution?.email, + webhook: formValues.offboardingDefaults?.postExecution?.webhook, + }, }, }; diff --git a/src/components/CippComponents/CippSharePointPermissionEditor.jsx b/src/components/CippComponents/CippSharePointPermissionEditor.jsx new file mode 100644 index 000000000000..ae4cb2e903db --- /dev/null +++ b/src/components/CippComponents/CippSharePointPermissionEditor.jsx @@ -0,0 +1,89 @@ +import { useFieldArray } from "react-hook-form"; +import { Alert, Box, Button, IconButton, Stack, Typography, Tooltip } from "@mui/material"; +import { Grid } from "@mui/system"; +import { Add, Delete } from "@mui/icons-material"; +import CippFormComponent from "./CippFormComponent"; + +// SharePoint built-in permission levels. Used for both site-root and per-library grants. +export const SHAREPOINT_PERMISSION_LEVELS = [ + { label: "Read", value: "read" }, + { label: "Contribute", value: "contribute" }, + { label: "Edit", value: "edit" }, + { label: "Design", value: "design" }, + { label: "Full Control", value: "fullControl" }, +]; + +// Reusable list of { principal, permissionLevel } grants backed by a react-hook-form field array. +// Groups are referenced by display name only (the same convention CA templates use): the name is +// matched against each target tenant at deploy time, so templates stay tenant-agnostic. `name` is +// the dot-notation path to the array (e.g. "siteTemplates.0.permissions"). +export const CippSharePointPermissionEditor = ({ + formControl, + name, + emptyText = "No permissions configured.", +}) => { + const { fields, append, remove } = useFieldArray({ + control: formControl.control, + name, + }); + + return ( + + + Groups are referenced by display name. During deployment the name is matched against each + target tenant, so the same template works everywhere. + + {fields.length === 0 && ( + + {emptyText} + + )} + + {fields.map((field, index) => ( + + + + + + + + + + remove(index)} + > + + + + + + ))} + + + + ); +}; + +export default CippSharePointPermissionEditor; diff --git a/src/components/CippComponents/CippSharePointTemplateBuilder.jsx b/src/components/CippComponents/CippSharePointTemplateBuilder.jsx new file mode 100644 index 000000000000..dd04ef6797b5 --- /dev/null +++ b/src/components/CippComponents/CippSharePointTemplateBuilder.jsx @@ -0,0 +1,1054 @@ +import { useState, useEffect } from "react"; +import { useFieldArray, useWatch, Controller } from "react-hook-form"; +import { + Box, + Card, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + IconButton, + InputBase, + ListItemIcon, + ListItemText, + Menu, + MenuItem, + Skeleton, + Stack, + Tooltip, + Typography, + Button, +} from "@mui/material"; +import { + Add, + Category, + Delete, + Folder, + Label, + Lock, + MoreVert, + Translate, + VpnKey, + ViewColumn, + Web, +} from "@mui/icons-material"; +import CippFormComponent from "./CippFormComponent"; +import { CippSharePointPermissionEditor } from "./CippSharePointPermissionEditor"; +import SharePointIcon from "../../icons/iconly/bulk/sharepoint"; +import TeamsIcon from "../../icons/iconly/bulk/teams"; + +export const SITE_TYPE_OPTIONS = [ + { label: "SharePoint site", value: "sharePoint" }, + { label: "Microsoft Teams", value: "teams" }, +]; + +// Current provisioning engine version stamped on save. Schemas live in +// frontend/src/data/sharePointTemplateSchemas.json (array keyed by templateEngineVersion). +export const SHAREPOINT_TEMPLATE_ENGINE_VERSION = 1; + +// Team site vs Communication when deploying as SharePoint (not a Microsoft Team). +// Values match New-CIPPSharepointSite -TemplateName. +export const CREATE_AS_DEFAULT = "Team"; +export const CREATE_AS_OPTIONS = [ + { label: "Team site", value: "Team" }, + { label: "Communication site", value: "Communication" }, +]; + +// SharePoint Online site-creation language LCIDs (UI language picker), plus tenant default. +// Regional variants such as en-GB are not supported by SPO site creation. +// Keep numeric values in sync with $AllowedSiteLcids in New-CIPPSharepointSite.ps1. +export const SITE_LANGUAGE_DEFAULT = "default"; +export const SITE_LANGUAGE_OPTIONS = [ + { label: "Tenant default", value: SITE_LANGUAGE_DEFAULT }, + { label: "Arabic", value: "1025" }, + { label: "Basque", value: "1069" }, + { label: "Bulgarian", value: "1026" }, + { label: "Catalan", value: "1027" }, + { label: "Chinese (Simplified)", value: "2052" }, + { label: "Chinese (Traditional)", value: "1028" }, + { label: "Croatian", value: "1050" }, + { label: "Czech", value: "1029" }, + { label: "Danish", value: "1030" }, + { label: "Dutch", value: "1043" }, + { label: "English", value: "1033" }, + { label: "Estonian", value: "1061" }, + { label: "Finnish", value: "1035" }, + { label: "French", value: "1036" }, + { label: "Galician", value: "1110" }, + { label: "German", value: "1031" }, + { label: "Greek", value: "1032" }, + { label: "Hebrew", value: "1037" }, + { label: "Hindi", value: "1081" }, + { label: "Hungarian", value: "1038" }, + { label: "Indonesian", value: "1057" }, + { label: "Italian", value: "1040" }, + { label: "Japanese", value: "1041" }, + { label: "Kazakh", value: "1087" }, + { label: "Korean", value: "1042" }, + { label: "Latvian", value: "1062" }, + { label: "Lithuanian", value: "1063" }, + { label: "Malay", value: "1086" }, + { label: "Norwegian (Bokmål)", value: "1044" }, + { label: "Polish", value: "1045" }, + { label: "Portuguese (Brazil)", value: "1046" }, + { label: "Portuguese (Portugal)", value: "2070" }, + { label: "Romanian", value: "1048" }, + { label: "Russian", value: "1049" }, + { label: "Serbian (Latin)", value: "2074" }, + { label: "Slovak", value: "1051" }, + { label: "Slovenian", value: "1060" }, + { label: "Spanish", value: "3082" }, + { label: "Swedish", value: "1053" }, + { label: "Thai", value: "1054" }, + { label: "Turkish", value: "1055" }, + { label: "Ukrainian", value: "1058" }, + { label: "Vietnamese", value: "1066" }, + { label: "Welsh", value: "1106" }, +]; + +const siteTypeIcon = (siteType) => (siteType === "teams" ? TeamsIcon : SharePointIcon); + +// Faint grayscale watermark only; header product marks stay in color. +const watermarkTypeIconSx = { filter: "grayscale(1)" }; + +const resolveSiteType = (value) => (value === "teams" ? "teams" : "sharePoint"); + +const resolveSiteLanguage = (value) => { + const raw = value?.value ?? value; + if (raw === undefined || raw === null || raw === "" || raw === SITE_LANGUAGE_DEFAULT) { + return SITE_LANGUAGE_DEFAULT; + } + return String(raw); +}; + +/** Option object for the language autocomplete (so the dialog shows "Tenant default", not "default"). */ +export const getSiteLanguageOption = (value) => { + const resolved = resolveSiteLanguage(value); + return ( + SITE_LANGUAGE_OPTIONS.find((option) => option.value === resolved) ?? SITE_LANGUAGE_OPTIONS[0] + ); +}; + +export { resolveSiteLanguage }; + +const resolveCreateAs = (value) => { + const raw = value?.value ?? value; + return raw === "Communication" ? "Communication" : CREATE_AS_DEFAULT; +}; + +export { resolveCreateAs }; + +const newLibrary = () => ({ name: "", description: "", permissions: [] }); +const newSiteTemplate = (siteType = "sharePoint") => ({ + displayName: "", + alias: "", + siteType: resolveSiteType(siteType), + language: getSiteLanguageOption(SITE_LANGUAGE_DEFAULT), + createAs: CREATE_AS_DEFAULT, + permissions: [], + libraries: [], +}); + +/** True when a site card has issues that keep Save disabled (name, root perms, library names). */ +export const siteTemplateBlocksSave = (site) => { + if (!site?.displayName?.trim()) return true; + if (!Array.isArray(site?.permissions) || site.permissions.length === 0) return true; + if ((site?.libraries || []).some((lib) => !lib?.name?.trim())) return true; + return false; +}; + +/** Human-readable save blockers for site templates (for the Save footer info tooltip). */ +export const getSiteTemplateSaveIssues = (sites = []) => { + const issues = []; + sites.forEach((site, index) => { + const label = site?.displayName?.trim() || `Site Template ${index + 1}`; + if (!site?.displayName?.trim()) { + issues.push(`${label}: needs a name`); + } + if (!Array.isArray(site?.permissions) || site.permissions.length === 0) { + issues.push(`${label}: root permissions required`); + } + if ((site?.libraries || []).some((lib) => !lib?.name?.trim())) { + issues.push(`${label}: every library needs a name`); + } + }); + return issues; +}; + +const CARD_WIDTH = 320; + +// One document library row inside a site card. Shows a lock when it carries unique permissions and +// a "..." menu mirroring the mock-up (Configure Permissions / Add Column / Manage Metadata). +const LibraryRow = ({ formControl, name, onRemove, onConfigurePermissions }) => { + const [anchorEl, setAnchorEl] = useState(null); + const permissions = useWatch({ control: formControl.control, name: `${name}.permissions` }); + const libraryName = useWatch({ control: formControl.control, name: `${name}.name` }); + const permCount = Array.isArray(permissions) ? permissions.length : 0; + const openMenu = Boolean(anchorEl); + const missingName = !libraryName?.trim(); + + return ( + + + + + ( + + )} + /> + {permCount > 0 && ( + 1 ? "s" : ""}`}> + + + )} + + setAnchorEl(e.currentTarget)} + > + + + + setAnchorEl(null)}> + { + setAnchorEl(null); + onConfigurePermissions(); + }} + > + + + + {permCount > 0 ? "Edit Permissions" : "Add Permissions"} + + + + + + + + Add Column + + + + + + + + + Manage Metadata + + + + + { + setAnchorEl(null); + onRemove(); + }} + sx={{ color: "error.main" }} + > + + + + Remove Library + + + + ); +}; + +// Site type editor shown from the site card "..." menu. +const SiteTypeDialog = ({ formControl, name, overrideActive, onClose }) => ( + + Site type + + {name && ( + + + {overrideActive ? ( + + Template override is active — all sites use the template site type at deploy. + + ) : ( + + Choose whether this entry provisions a SharePoint site or a Microsoft Team. + + )} + + )} + + + + + +); + +// Site language editor shown from the site card "..." menu. +const SiteLanguageDialog = ({ formControl, name, onClose }) => { + // Ensure the field always has a real option object when the dialog opens so the + // autocomplete shows "Tenant default" (or the saved language) instead of blank/raw values. + useEffect(() => { + if (!name) return; + const fieldName = `${name}.language`; + formControl.setValue(fieldName, getSiteLanguageOption(formControl.getValues(fieldName)), { + shouldDirty: false, + shouldTouch: false, + shouldValidate: false, + }); + }, [name, formControl]); + + return ( + + Site language + + {name && ( + + + + Tenant default follows each target tenant's SharePoint root site language + (the SPO default for new sites) at deploy. A specific language is applied when + the SharePoint site is created. + + + )} + + + + + + ); +}; + +// Team site vs Communication site — SharePoint path only; ignored for Teams. +const CreateAsDialog = ({ formControl, name, onClose }) => { + useEffect(() => { + if (!name) return; + const fieldName = `${name}.createAs`; + formControl.setValue(fieldName, resolveCreateAs(formControl.getValues(fieldName)), { + shouldDirty: false, + shouldTouch: false, + shouldValidate: false, + }); + }, [name, formControl]); + + return ( + + Create as + + {name && ( + + + + Team site is a collaboration workspace. Communication site is for publishing + (intranet, news). Only applies when this card deploys as a SharePoint site. + + + )} + + + + + + ); +}; + +// A single site template card: coloured header, libraries, and "..." menu for permissions, +// site type, and remove. Header icon + watermark reflect the effective site type. +const SiteTemplateCard = ({ formControl, name, index, onRemove, onConfigurePermissions }) => { + const [anchorEl, setAnchorEl] = useState(null); + const [siteTypeOpen, setSiteTypeOpen] = useState(false); + const [siteLanguageOpen, setSiteLanguageOpen] = useState(false); + const [createAsOpen, setCreateAsOpen] = useState(false); + const permissions = useWatch({ control: formControl.control, name: `${name}.permissions` }); + const displayName = useWatch({ control: formControl.control, name: `${name}.displayName` }); + const libraries = useWatch({ control: formControl.control, name: `${name}.libraries` }); + const cardSiteType = useWatch({ control: formControl.control, name: `${name}.siteType` }); + const createAs = useWatch({ control: formControl.control, name: `${name}.createAs` }); + const overrideSiteType = useWatch({ control: formControl.control, name: "overrideSiteType" }); + const templateSiteType = useWatch({ control: formControl.control, name: "siteType" }); + const permCount = Array.isArray(permissions) ? permissions.length : 0; + const openMenu = Boolean(anchorEl); + const overrideActive = !!overrideSiteType; + const effectiveSiteType = resolveSiteType(overrideActive ? templateSiteType : cardSiteType); + const TypeIcon = siteTypeIcon(effectiveSiteType); + const createAsLabel = + effectiveSiteType === "teams" + ? "Microsoft Team" + : resolveCreateAs(createAs) === "Communication" + ? "Communication site" + : "Team site"; + + const { fields, append, remove } = useFieldArray({ + control: formControl.control, + name: `${name}.libraries`, + }); + + // Flag anything on this card that keeps Save disabled (same rules as form + save checks). + const missingDisplayName = !displayName?.trim(); + const missingRootPerms = permCount === 0; + const incompleteLibraries = (libraries || []).some((lib) => !lib?.name?.trim()); + const cardBlocksSave = missingDisplayName || missingRootPerms || incompleteLibraries; + + return ( + + {/* Brand header + subheader — clearly divided bars */} + + + + { + if (overrideActive) return; + setSiteTypeOpen(true); + }} + aria-label={ + overrideActive + ? effectiveSiteType === "teams" + ? "Microsoft Teams" + : "SharePoint site" + : "Change site type" + } + sx={{ + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + lineHeight: 0, + p: 0.25, + m: 0, + border: "none", + borderRadius: 1, + background: "none", + color: "inherit", + cursor: overrideActive ? "default" : "pointer", + opacity: overrideActive ? 0.7 : 1, + "&:hover": overrideActive + ? undefined + : { bgcolor: "rgba(255,255,255,0.12)" }, + }} + > + + + + ( + + )} + /> + 0 + ? `${permCount} site-level permission${permCount > 1 ? "s" : ""} — click to edit` + : "Root permissions required — click to add" + } + > + 0 ? "Edit site permissions" : "Add site permissions" + } + onClick={() => onConfigurePermissions()} + sx={{ color: permCount > 0 ? "#fff" : "error.main" }} + > + + + + + setAnchorEl(e.currentTarget)} + sx={{ color: "#fff" }} + > + + + + + + + {createAsLabel} + + + setAnchorEl(null)}> + { + setAnchorEl(null); + onConfigurePermissions(); + }} + > + + + + {permCount > 0 ? "Edit Site Permissions" : "Add Site Permissions"} + + { + if (overrideActive) return; + setAnchorEl(null); + setSiteTypeOpen(true); + }} + > + + + + + + {effectiveSiteType === "sharePoint" && ( + { + setAnchorEl(null); + setSiteLanguageOpen(true); + }} + > + + + + + + )} + {effectiveSiteType === "sharePoint" && ( + { + setAnchorEl(null); + setCreateAsOpen(true); + }} + > + + + + + + )} + + { + setAnchorEl(null); + onRemove(); + }} + sx={{ color: "error.main" }} + > + + + + Remove Site Template + + + + + {/* Body: document libraries + faint type watermark */} + + + + + + Document Libraries + + + {fields.map((field, libIndex) => ( + remove(libIndex)} + onConfigurePermissions={() => + onConfigurePermissions(`${name}.libraries.${libIndex}.permissions`, "Library") + } + /> + ))} + append(newLibrary())} + sx={{ + display: "flex", + alignItems: "center", + gap: 1, + px: 1, + py: 0.75, + mt: 0.5, + borderRadius: 1, + cursor: "pointer", + color: "primary.main", + "&:hover": { bgcolor: "action.hover" }, + }} + > + + Add Library + + + + + setSiteTypeOpen(false)} + /> + setSiteLanguageOpen(false)} + /> + setCreateAsOpen(false)} + /> + + ); +}; + +// Dashed add card with two peer buttons: SharePoint site or Microsoft Teams. +const AddSiteCard = ({ onAddSharePoint, onAddTeams }) => ( + + + Add New Site Template + + + + + + +); + +// Small stat tile used by the Quick Stats panel. +const Stat = ({ label, value }) => ( + + + {label} + + {value} + +); + +export const CippSharePointTemplateQuickStats = ({ formControl, sx }) => { + const siteTemplates = useWatch({ control: formControl.control, name: "siteTemplates" }) || []; + const overrideSiteType = useWatch({ control: formControl.control, name: "overrideSiteType" }); + const templateSiteType = useWatch({ control: formControl.control, name: "siteType" }); + const overrideActive = !!overrideSiteType; + + const libraryCount = siteTemplates.reduce( + (total, site) => total + (Array.isArray(site?.libraries) ? site.libraries.length : 0), + 0 + ); + const permissionCount = siteTemplates.reduce((total, site) => { + const sitePerms = Array.isArray(site?.permissions) ? site.permissions.length : 0; + const libPerms = Array.isArray(site?.libraries) + ? site.libraries.reduce( + (sum, lib) => sum + (Array.isArray(lib?.permissions) ? lib.permissions.length : 0), + 0 + ) + : 0; + return total + sitePerms + libPerms; + }, 0); + + // Counts respect the section override so they match what deploy will create. + const teamsCount = siteTemplates.filter((site) => { + const effective = overrideActive ? templateSiteType : site?.siteType; + return effective === "teams"; + }).length; + const sharePointCount = siteTemplates.length - teamsCount; + + return ( + + + Quick Stats + + + + + + + + + ); +}; + +export const CippSharePointTemplateQuickStatsSkeleton = () => ( + + + + + + + + + +); + +// Permission editor rendered in a dialog, targeting whichever field-array path was requested. +const PermissionDialog = ({ formControl, target, onClose }) => ( + + {target?.title === "Library" ? "Library Permissions" : "Site Permissions"} + + {target && ( + + )} + + + + + +); + +// Dialog for the section-level site type override (one menu entry → this dialog). +const SiteTypeOverrideDialog = ({ formControl, open, onClose }) => { + const overrideActive = !!useWatch({ control: formControl.control, name: "overrideSiteType" }); + + return ( + + Override site types + + + + Force every site in this template to deploy as the same type, ignoring each card's + own site type. + + + + {!overrideActive && ( + + Turn on the override to choose the type applied to every site. + + )} + + + + + + + ); +}; + +// The card-canvas builder: site-template cards plus dual SharePoint/Teams add buttons. +// Site-type override lives in the section actions menu. +export const CippSharePointTemplateBuilder = ({ formControl }) => { + const [permTarget, setPermTarget] = useState(null); + const [actionsAnchor, setActionsAnchor] = useState(null); + const [overrideDialogOpen, setOverrideDialogOpen] = useState(false); + const overrideSiteType = useWatch({ control: formControl.control, name: "overrideSiteType" }); + const templateSiteType = useWatch({ control: formControl.control, name: "siteType" }); + const overrideActive = !!overrideSiteType; + const OverrideIcon = siteTypeIcon(resolveSiteType(templateSiteType)); + const { fields, append, remove } = useFieldArray({ + control: formControl.control, + name: "siteTemplates", + }); + + const handleConfigurePermissions = (name, title) => setPermTarget({ name, title }); + + return ( + + + + Site Templates + + Each site template provisions a SharePoint site or Microsoft Team and its document + libraries. + + {overrideActive && ( + + + Site type override active — all sites deploy as{" "} + {resolveSiteType(templateSiteType) === "teams" ? "Microsoft Teams" : "SharePoint sites"}. + + )} + + + + setActionsAnchor(e.currentTarget)} + > + + + + setActionsAnchor(null)} + > + { + setActionsAnchor(null); + setOverrideDialogOpen(true); + }} + > + + + + + + + + + + + {fields.map((field, index) => ( + remove(index)} + onConfigurePermissions={(target, title) => + target + ? handleConfigurePermissions(target, title) + : handleConfigurePermissions(`siteTemplates.${index}.permissions`, "Site") + } + /> + ))} + append(newSiteTemplate("sharePoint"))} + onAddTeams={() => append(newSiteTemplate("teams"))} + /> + + + setPermTarget(null)} + /> + setOverrideDialogOpen(false)} + /> + + ); +}; + +// Loading placeholder for the site card canvas, shown while an existing template is fetched. +export const CippSharePointTemplateBuilderSkeleton = () => ( + + + + + + + {Array.from({ length: 2 }).map((_, index) => ( + + + + + + + + + + ))} + + +); + +export default CippSharePointTemplateBuilder; diff --git a/src/components/CippComponents/CippSharePointTemplateDeployDrawer.jsx b/src/components/CippComponents/CippSharePointTemplateDeployDrawer.jsx new file mode 100644 index 000000000000..a18f570c3ee9 --- /dev/null +++ b/src/components/CippComponents/CippSharePointTemplateDeployDrawer.jsx @@ -0,0 +1,168 @@ +import { useEffect, useState } from "react"; +import { Button } from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm, useWatch } from "react-hook-form"; +import { RocketLaunch } from "@mui/icons-material"; +import { CippOffCanvas } from "./CippOffCanvas"; +import CippFormComponent from "./CippFormComponent"; +import { CippFormTenantSelector } from "./CippFormTenantSelector"; +import { CippApiResults } from "./CippApiResults"; +import { ApiPostCall } from "../../api/ApiCall"; + +// Deploy an existing SharePoint provisioning template to one tenant. Live progress is +// rendered by CippApiResults via its jobProgress option. Tenant is singular because the +// Site/Team Owner picker is tied to a single tenant context. +export const CippSharePointTemplateDeployDrawer = ({ + buttonText = "Deploy Template", + requiredPermissions = [], + PermissionButton = Button, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const formControl = useForm({ + mode: "onChange", + defaultValues: { template: null, siteOwner: null, tenantFilter: null }, + }); + const tenantFilter = useWatch({ control: formControl.control, name: "tenantFilter" }); + const tenantFilterValue = tenantFilter?.value; + + // Owner is tenant-scoped — clear it whenever the deploy target changes. + useEffect(() => { + formControl.setValue("siteOwner", null, { + shouldDirty: false, + shouldTouch: false, + shouldValidate: false, + }); + }, [tenantFilterValue, formControl]); + + const deployTemplate = ApiPostCall({ + relatedQueryKeys: ["ListSharePointTemplates"], + }); + + const handleSubmit = () => { + const values = formControl.getValues(); + deployTemplate.mutate({ + url: "/api/ExecSharePointTemplate?Action=Deploy", + data: { + TemplateId: values.template?.value, + SiteOwner: values.siteOwner?.value ?? values.siteOwner, + tenantFilter: values.tenantFilter?.value, + }, + }); + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset(); + }; + + return ( + <> + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + + + + + } + > + + + + + + + + + `${user.displayName} (${user.userPrincipalName})`, + valueField: "userPrincipalName", + addedField: { + id: "id", + }, + }} + /> + + + `/api/ExecSharePointTemplate?Action=DeployStatus&DeploymentId=${id}`, + }} + /> + + + + + ); +}; + +export default CippSharePointTemplateDeployDrawer; 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/CippTenantGroupOffCanvas.jsx b/src/components/CippComponents/CippTenantGroupOffCanvas.jsx index 05ed8e18f836..490818307141 100644 --- a/src/components/CippComponents/CippTenantGroupOffCanvas.jsx +++ b/src/components/CippComponents/CippTenantGroupOffCanvas.jsx @@ -46,6 +46,9 @@ export const CippTenantGroupOffCanvas = ({ data }) => { ne: "not equals", in: "in", notIn: "not in", + notin: "not in", + like: "contains", + notlike: "does not contain", contains: "contains", startsWith: "starts with", endsWith: "ends with", @@ -54,6 +57,54 @@ export const CippTenantGroupOffCanvas = ({ data }) => { // Handle both single rule object and array of rules const rules = Array.isArray(data.DynamicRules) ? data.DynamicRules : [data.DynamicRules]; + // Resolve a value that may be a string, a {label, value} option, or a raw object + const resolveOptionLabel = (item) => { + if (item === null || item === undefined) return ""; + if (typeof item === "object") return item.label ?? item.value ?? JSON.stringify(item); + return item; + }; + + const renderRuleValue = (rule) => { + // Custom Variable rules store a nested { variableName, value } object + if (rule.property === "customVariable" || rule.value?.variableName !== undefined) { + const variableName = resolveOptionLabel(rule.value?.variableName); + const expectedValue = resolveOptionLabel(rule.value?.value); + return ( + + ); + } + + if (Array.isArray(rule.value)) { + return ( + + {rule.value.map((item, valueIndex) => ( + + ))} + + ); + } + + return ( + + ); + }; + const renderRule = (rule, index) => ( { Value(s): - {Array.isArray(rule.value) ? ( - - {rule.value.map((item, valueIndex) => ( - - ))} - - ) : ( - - )} + {renderRuleValue(rule)} ); diff --git a/src/components/CippComponents/CippTenantGroupRuleBuilder.jsx b/src/components/CippComponents/CippTenantGroupRuleBuilder.jsx index e61aaf768be5..9fde8f00ccb3 100644 --- a/src/components/CippComponents/CippTenantGroupRuleBuilder.jsx +++ b/src/components/CippComponents/CippTenantGroupRuleBuilder.jsx @@ -40,9 +40,11 @@ const CippTenantGroupRuleBuilder = ({ formControl, name = "dynamicRules" }) => { // Flatten all pages and extract Results const allGroups = tenantGroupsQuery.data.pages.flatMap((page) => page?.Results || []); return allGroups - .filter((group) => group.GroupType === "static") .map((group) => ({ - label: group.Name || group.displayName, + label: + group.GroupType === "dynamic" + ? `${group.Name || group.displayName} (dynamic)` + : group.Name || group.displayName, value: group.Id || group.RowKey, type: group.GroupType, })) @@ -188,6 +190,16 @@ const CippTenantGroupRuleBuilder = ({ formControl, name = "dynamicRules" }) => { />
    + ) : watchedRules?.[ruleIndex]?.property?.type === "gdapAge" ? ( + ) : ( { "Member of Tenant Group equals 'Production Tenants'" {" | "} "Custom Variable: Environment equals Production" + {" | "} + "GDAP Relationship Age (days) Greater Than or Equal 14" {/* Logic Operator Selection */} 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/CippTimeAgo.jsx b/src/components/CippComponents/CippTimeAgo.jsx index a97d71e03acc..9573b4cfa936 100644 --- a/src/components/CippComponents/CippTimeAgo.jsx +++ b/src/components/CippComponents/CippTimeAgo.jsx @@ -1,11 +1,10 @@ import { Chip } from "@mui/material"; import ReactTimeAgo from "react-time-ago"; +import { parseCippDate } from "../../utils/parse-cipp-date"; export const CippTimeAgo = ({ data, type = "text", timeStyle = "round-minute" }) => { const isText = type === "text"; - const numberRegex = /^\d+$/; - const date = - typeof data === "number" || numberRegex.test(data) ? new Date(data * 1000) : new Date(data); + const date = parseCippDate(data); if (date.getTime() === 0) { return "Never"; diff --git a/src/components/CippComponents/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 2ac784716799..df67f0ee8c7d 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,28 +554,11 @@ 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, + allowResubmit: true, condition: () => canWriteUser, }, { @@ -732,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', @@ -741,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]?', @@ -762,6 +894,8 @@ export const useCippUserActions = () => { type: 'switch', name: 'MustChange', label: 'Must Change Password at Next Logon', + helperText: + 'Not supported for directory-synced (on-premises AD) accounts. Those resets go through password writeback, which always requires a change at next logon.', }, ], confirmText: 'Are you sure you want to reset the password for [userPrincipalName]?', @@ -813,6 +947,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', @@ -822,12 +967,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/ForcedSsoMigrationDialog.jsx b/src/components/CippComponents/ForcedSsoMigrationDialog.jsx index 1d5add90b7ee..4fc1157dae8c 100644 --- a/src/components/CippComponents/ForcedSsoMigrationDialog.jsx +++ b/src/components/CippComponents/ForcedSsoMigrationDialog.jsx @@ -14,7 +14,7 @@ import { } from '@mui/material' import { ApiGetCall, ApiPostCall } from '../../api/ApiCall' -export const ForcedSsoMigrationDialog = () => { +export const ForcedSsoMigrationDialog = ({ setupCompleted = true }) => { const [multiTenant, setMultiTenant] = useState(false) const [submitted, setSubmitted] = useState(false) @@ -31,7 +31,14 @@ export const ForcedSsoMigrationDialog = () => { const forceSsoMigration = currentRole.data?.forceSsoMigration const hasPermission = permissions.includes('CIPP.AppSettings.ReadWrite') - const open = !!(currentRole.isSuccess && hasPermission && forceSsoMigration?.status === 'pending') + // Hold the forced migration behind initial setup — the setup wizard must be + // reachable (and the SAM app configured) before SSO migration can succeed. + const open = !!( + currentRole.isSuccess && + hasPermission && + forceSsoMigration?.status === 'pending' && + setupCompleted + ) const result = ssoSetup.data?.data?.Results ?? ssoSetup.data?.Results const isSuccess = result?.severity === 'success' diff --git a/src/components/CippComponents/LicenseCard.jsx b/src/components/CippComponents/LicenseCard.jsx index dce02b1e12f6..5e59011903b3 100644 --- a/src/components/CippComponents/LicenseCard.jsx +++ b/src/components/CippComponents/LicenseCard.jsx @@ -1,8 +1,10 @@ import { Box, Card, CardHeader, CardContent, Typography, Divider, Skeleton } from "@mui/material"; import { CardMembership as CardMembershipIcon } from "@mui/icons-material"; import { CippSankey } from "./CippSankey"; +import { useRouter } from "next/router"; export const LicenseCard = ({ data, isLoading }) => { + const router = useRouter(); const processData = () => { if (!data || !Array.isArray(data) || data.length === 0) { return null; @@ -19,6 +21,7 @@ export const LicenseCard = ({ data, isLoading }) => { const nodes = []; const links = []; + const licenseLookup = {}; topLicenses.forEach((license, index) => { if (license) { @@ -30,22 +33,33 @@ export const LicenseCard = ({ data, isLoading }) => { const assigned = parseInt(license?.CountUsed || 0) || 0; const available = parseInt(license?.CountAvailable || 0) || 0; + // Use the index to keep node ids unique even when two licenses truncate + // to the same shortName; the visible label stays the truncated name. + const nodeId = `${index}-${shortName}`; + const assignedId = `${nodeId} - Assigned`; + const availableId = `${nodeId} - Available`; + nodes.push({ - id: shortName, + id: nodeId, + label: shortName, nodeColor: `hsl(${210 + index * 30}, 70%, 50%)`, }); - const assignedId = `${shortName} - Assigned`; - const availableId = `${shortName} - Available`; + // Map every node id back to the full license name so a click can filter + // the report on the real License value. + licenseLookup[nodeId] = licenseName; + licenseLookup[assignedId] = licenseName; + licenseLookup[availableId] = licenseName; if (assigned > 0) { nodes.push({ id: assignedId, + label: `${shortName} - Assigned`, nodeColor: "hsl(99, 70%, 50%)", }); links.push({ - source: shortName, + source: nodeId, target: assignedId, value: assigned, }); @@ -54,11 +68,12 @@ export const LicenseCard = ({ data, isLoading }) => { if (available > 0) { nodes.push({ id: availableId, + label: `${shortName} - Available`, nodeColor: "hsl(28, 100%, 53%)", }); links.push({ - source: shortName, + source: nodeId, target: availableId, value: available, }); @@ -70,11 +85,30 @@ export const LicenseCard = ({ data, isLoading }) => { return null; } - return { nodes, links }; + return { nodes, links, licenseLookup }; }; const processedData = processData(); + const navigateToLicense = (nodeId) => { + const fullName = processedData?.licenseLookup?.[nodeId]; + if (!fullName) { + return; + } + router.push({ + pathname: "/tenant/reports/list-licenses", + query: { filters: JSON.stringify([{ id: "License", value: fullName }]) }, + }); + }; + + const handleNodeClick = (node) => { + navigateToLicense(node?.id); + }; + + const handleLinkClick = (link) => { + navigateToLicense(link?.source?.id ?? link?.source); + }; + const calculateStats = () => { if (!data || !Array.isArray(data)) { return { total: 0, assigned: 0, available: 0 }; @@ -93,7 +127,17 @@ export const LicenseCard = ({ data, isLoading }) => { + router.push("/tenant/reports/list-licenses")} + sx={{ + display: "flex", + alignItems: "center", + gap: 1, + cursor: "pointer", + width: "fit-content", + "&:hover": { textDecoration: "underline" }, + }} + > License Overview @@ -105,7 +149,11 @@ export const LicenseCard = ({ data, isLoading }) => { {isLoading ? ( ) : processedData ? ( - + ) : ( { + router.push("/identity/reports/mfa-report")} + sx={{ + display: "flex", + alignItems: "center", + gap: 1, + cursor: "pointer", + width: "fit-content", + "&:hover": { textDecoration: "underline" }, + }} + > User authentication diff --git a/src/components/CippComponents/SecureScoreCard.jsx b/src/components/CippComponents/SecureScoreCard.jsx index 51940abfc2d9..a1732a6d41d5 100644 --- a/src/components/CippComponents/SecureScoreCard.jsx +++ b/src/components/CippComponents/SecureScoreCard.jsx @@ -1,5 +1,6 @@ import { Box, Card, CardHeader, CardContent, Typography, Divider, Skeleton } from '@mui/material' import { Security as SecurityIcon } from '@mui/icons-material' +import { useRouter } from 'next/router' import { LineChart, Line, @@ -12,11 +13,22 @@ import { } from 'recharts' export const SecureScoreCard = ({ data, isLoading }) => { + const router = useRouter() return ( + router.push('/tenant/administration/securescore')} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 1, + cursor: 'pointer', + width: 'fit-content', + '&:hover': { textDecoration: 'underline' }, + }} + > Secure Score diff --git a/src/components/CippComponents/SsoMigrationDialog.jsx b/src/components/CippComponents/SsoMigrationDialog.jsx index cb4d9691b187..2d91c455170d 100644 --- a/src/components/CippComponents/SsoMigrationDialog.jsx +++ b/src/components/CippComponents/SsoMigrationDialog.jsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react' +import { useRouter } from 'next/router' import { Alert, Button, @@ -14,8 +15,11 @@ import { import { ApiPostCall } from '../../api/ApiCall' const DISMISS_KEY = 'cipp_sso_migration_dismissed' +const ERROR_DISMISS_KEY = 'cipp_sso_migration_error_dismissed' +const SSO_SETTINGS_PATH = '/cipp/advanced/super-admin/sso' export const SsoMigrationDialog = ({ meData }) => { + const router = useRouter() const [open, setOpen] = useState(false) const [multiTenant, setMultiTenant] = useState(false) const [submitted, setSubmitted] = useState(false) @@ -27,16 +31,21 @@ export const SsoMigrationDialog = ({ meData }) => { const permissions = meData?.permissions || [] const ssoMigration = meData?.ssoMigration const hasPermission = permissions.includes('CIPP.AppSettings.ReadWrite') + const status = ssoMigration?.status + const isErrorState = status === 'error' useEffect(() => { if (!meData || !ssoMigration) return - if (ssoMigration.status !== 'none') return + if (status !== 'none' && status !== 'error') return - const dismissedAt = localStorage.getItem(DISMISS_KEY) + // Dismissals are tracked per state so hiding the "get ready" nag doesn't also + // hide a migration that later fails. + const dismissKey = status === 'error' ? ERROR_DISMISS_KEY : DISMISS_KEY + const dismissedAt = localStorage.getItem(dismissKey) if (dismissedAt && Date.now() - Number(dismissedAt) < 24 * 60 * 60 * 1000) return setOpen(true) - }, [meData, ssoMigration]) + }, [meData, ssoMigration, status]) const handleApprove = useCallback(() => { setSubmitted(true) @@ -50,14 +59,56 @@ export const SsoMigrationDialog = ({ meData }) => { }, [multiTenant, ssoSetup]) const handleDismiss = useCallback(() => { - localStorage.setItem(DISMISS_KEY, String(Date.now())) + localStorage.setItem(isErrorState ? ERROR_DISMISS_KEY : DISMISS_KEY, String(Date.now())) setOpen(false) - }, []) + }, [isErrorState]) + + const handleGoToSsoSettings = useCallback(() => { + setOpen(false) + router.push(SSO_SETTINGS_PATH) + }, [router]) const handleClose = useCallback(() => { setOpen(false) }, []) + // A failed migration can't be retried from here — the SSO settings page surfaces the + // underlying error and the Repair action that resumes from where setup stopped. + if (isErrorState) { + return ( + + CIPP Single Sign-On Setup Incomplete + + + The CIPP-SSO app registration could not be set up automatically, so the migration is + incomplete. + + + The SSO settings page shows the specific error and lets you finish setup manually. In + most cases Repair picks up from where it stopped, so the existing app + registration is reused rather than recreated. + + {!hasPermission && ( + + Only users with App Settings permissions can complete the SSO setup. Please ask an + administrator to finish this step. + + )} + + + + {hasPermission && ( + + )} + + + ) + } + const result = ssoSetup.data?.data?.Results ?? ssoSetup.data?.Results const isSuccess = result?.severity === 'success' const isPartial = result?.severity === 'warning' && result?.canRepair diff --git a/src/components/CippComponents/TenantMetricsGrid.jsx b/src/components/CippComponents/TenantMetricsGrid.jsx index 323bd44a7f9f..35eda0143286 100644 --- a/src/components/CippComponents/TenantMetricsGrid.jsx +++ b/src/components/CippComponents/TenantMetricsGrid.jsx @@ -84,12 +84,13 @@ export const TenantMetricsGrid = ({ data, isLoading }) => { sx={{ display: "flex", alignItems: "center", - gap: 1.5, - p: 2, + gap: { xs: 1, sm: 1.5 }, + p: { xs: 1, sm: 1.5, md: 2 }, border: 1, borderColor: "divider", borderRadius: 1, cursor: "pointer", + minWidth: 0, transition: "all 0.2s ease-in-out", "&:hover": { borderColor: `${metric.color}.main`, @@ -103,18 +104,24 @@ export const TenantMetricsGrid = ({ data, isLoading }) => { sx={{ bgcolor: `${metric.color}.main`, color: `${metric.color}.contrastText`, - width: 34, - height: 34, + width: { xs: 28, sm: 32, md: 34 }, + height: { xs: 28, sm: 32, md: 34 }, + flexShrink: 0, }} > - + - - + + {metric.label} - - {isLoading ? : formatNumber(metric.value)} + + {isLoading ? : formatNumber(metric.value)} diff --git a/src/components/CippFormPages/CippAddEditUser.jsx b/src/components/CippFormPages/CippAddEditUser.jsx index 9a3559190372..f5b9be6ffd82 100644 --- a/src/components/CippFormPages/CippAddEditUser.jsx +++ b/src/components/CippFormPages/CippAddEditUser.jsx @@ -20,6 +20,9 @@ const CippAddEditUser = (props) => { const [selectedTemplate, setSelectedTemplate] = useState(null) const [displayNameManuallySet, setDisplayNameManuallySet] = useState(false) const [usernameManuallySet, setUsernameManuallySet] = useState(false) + // Tracks the template already applied to the form so we can tell a first + // apply (fill empty fields) apart from a switch (replace/clear stale values) + const appliedTemplateKeyRef = useRef(null) const router = useRouter() const { userId } = router.query @@ -50,7 +53,7 @@ const CippAddEditUser = (props) => { // Get all groups for the tenant const tenantGroups = ApiGetCall({ url: `/api/ListGroups?tenantFilter=${tenantDomain}`, - queryKey: `ListGroups-${tenantDomain}`, + queryKey: `TenantGroupsList-${tenantDomain}`, refetchOnMount: false, refetchOnReconnect: false, }) @@ -71,6 +74,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) { @@ -242,6 +264,7 @@ const CippAddEditUser = (props) => { // Only clear selected template if it's not the default template if (selectedTemplate && !selectedTemplate.defaultForTenant) { setSelectedTemplate(null) + appliedTemplateKeyRef.current = null } } }, [ @@ -271,95 +294,123 @@ const CippAddEditUser = (props) => { // Auto-populate fields when template selected useEffect(() => { - if (formType === 'add' && watchedFields.userTemplate?.addedFields) { - const template = watchedFields.userTemplate.addedFields - setSelectedTemplate(template) + if (formType !== 'add' || !watchedFields.userTemplate?.addedFields) return + const template = watchedFields.userTemplate.addedFields + const templateKey = watchedFields.userTemplate.value ?? template.GUID ?? template.templateName - // Reset manual edit flags when template changes - setDisplayNameManuallySet(false) - setUsernameManuallySet(false) + // Distinguish the first apply from a switch. On a switch we replace + // template-driven fields (and clear ones the new template doesn't define) + // so stale values from the previous template don't linger. On the first + // apply we only fill fields that have a template value, so we don't clobber + // input the user already entered or copied from another user. + const isSwitch = + appliedTemplateKeyRef.current !== null && appliedTemplateKeyRef.current !== templateKey + appliedTemplateKeyRef.current = templateKey - // Only set fields if they don't already have values (don't override user input) - const setFieldIfEmpty = (fieldName, value) => { - if (value) { - formControl.setValue(fieldName, value) - } - } + setSelectedTemplate(template) - // Populate form fields from template - if (template.primDomain) { - // If primDomain is an object, use it as-is; if it's a string, convert to object - const primDomainValue = - typeof template.primDomain === 'string' - ? { label: template.primDomain, value: template.primDomain } - : template.primDomain - formControl.setValue('primDomain', primDomainValue) - } - if (template.usageLocation) { - // Handle both object and string formats - const usageLocationCode = - typeof template.usageLocation === 'string' - ? template.usageLocation - : template.usageLocation?.value - const country = countryList.find((c) => c.Code === usageLocationCode) - if (country) { - setFieldIfEmpty('usageLocation', { - label: country.Name, - value: country.Code, - }) - } - } - setFieldIfEmpty('jobTitle', template.jobTitle) - setFieldIfEmpty('streetAddress', template.streetAddress) - setFieldIfEmpty('city', template.city) - setFieldIfEmpty('state', template.state) - setFieldIfEmpty('postalCode', template.postalCode) - setFieldIfEmpty('country', template.country) - setFieldIfEmpty('companyName', template.companyName) - setFieldIfEmpty('department', template.department) - setFieldIfEmpty('mobilePhone', template.mobilePhone) - setFieldIfEmpty('businessPhones[0]', template.businessPhones) - - // Handle licenses - need to match the format expected by CippFormLicenseSelector - if (template.licenses && Array.isArray(template.licenses)) { - setFieldIfEmpty('licenses', template.licenses) + // Reset manual edit flags when template changes + setDisplayNameManuallySet(false) + setUsernameManuallySet(false) + + // Apply a template value to a field. When the template has a value we set + // it; when it doesn't and this is a switch we clear the field (emptyValue) + // so the previous template's value doesn't linger. + const applyField = (fieldName, value, emptyValue = '') => { + const hasValue = Array.isArray(value) + ? value.length > 0 + : value !== undefined && value !== null && value !== '' + if (hasValue) { + formControl.setValue(fieldName, value, { shouldDirty: true }) + } else if (isSwitch) { + formControl.setValue(fieldName, emptyValue, { shouldDirty: true }) } + } - // Handle groups from template - const templateGroups = template.addToGroups || template.groupMemberships - if (templateGroups) { - const rawGroups = Array.isArray(templateGroups) ? templateGroups : [templateGroups] - const groups = rawGroups.map((g) => { - if (g.label && g.value) return g - const groupType = g.groupTypes?.includes('Unified') - ? 'Microsoft 365' - : g.mailEnabled && !g.groupTypes?.includes('Unified') - ? g.securityEnabled - ? 'Mail-Enabled Security' - : 'Distribution list' - : 'Security' - return { - label: g.displayName, - value: g.id, - addedFields: { groupType }, - } - }) - if (groups.length > 0) { - const currentGroups = watchedFields.AddToGroups - if (!currentGroups || (Array.isArray(currentGroups) && currentGroups.length === 0)) { - formControl.setValue('AddToGroups', groups, { shouldDirty: true }) - } - } + // Primary domain - accept both object and string formats + const primDomainValue = template.primDomain + ? typeof template.primDomain === 'string' + ? { label: template.primDomain, value: template.primDomain } + : template.primDomain + : null + applyField('primDomain', primDomainValue, null) + + // Usage location - accept both object and string formats + const usageLocationCode = + typeof template.usageLocation === 'string' + ? template.usageLocation + : template.usageLocation?.value + const country = usageLocationCode + ? countryList.find((c) => c.Code === usageLocationCode) + : null + applyField( + 'usageLocation', + country ? { label: country.Name, value: country.Code } : null, + null + ) + + applyField('jobTitle', template.jobTitle) + applyField('streetAddress', template.streetAddress) + applyField('city', template.city) + applyField('state', template.state) + applyField('postalCode', template.postalCode) + applyField('country', template.country) + applyField('companyName', template.companyName) + applyField('department', template.department) + applyField('mobilePhone', template.mobilePhone) + + const templateBusinessPhone = Array.isArray(template.businessPhones) + ? template.businessPhones[0] + : template.businessPhones + applyField('businessPhones', templateBusinessPhone ? [templateBusinessPhone] : [], []) + + // Licenses - match the format expected by CippFormLicenseSelector + applyField( + 'licenses', + Array.isArray(template.licenses) ? template.licenses : [], + [] + ) + + // Groups from template + const templateGroups = template.addToGroups || template.groupMemberships + const rawGroups = templateGroups + ? Array.isArray(templateGroups) + ? templateGroups + : [templateGroups] + : [] + const groups = rawGroups.map((g) => { + if (g.label && g.value) return g + const groupType = g.groupTypes?.includes('Unified') + ? 'Microsoft 365' + : g.mailEnabled && !g.groupTypes?.includes('Unified') + ? g.securityEnabled + ? 'Mail-Enabled Security' + : 'Distribution list' + : 'Security' + return { + label: g.displayName, + value: g.id, + addedFields: { groupType }, } + }) + applyField('AddToGroups', groups, []) - // Populate custom user attributes from template - if (template.defaultAttributes) { - Object.entries(template.defaultAttributes).forEach(([key, attr]) => { - if (attr?.Value) { - setFieldIfEmpty(`defaultAttributes.${key}.Value`, attr.Value) - } + // Custom user attributes. On a switch, clear every known attribute field + // first so attributes the new template doesn't define don't linger, then + // apply the template's values. + if (isSwitch) { + userSettingsDefaults?.userAttributes + ?.filter((attribute) => attribute.value !== 'sponsor') + .forEach((attribute) => { + formControl.setValue(`defaultAttributes.${attribute.label}.Value`, '', { + shouldDirty: true, + }) }) - } + } + if (template.defaultAttributes) { + Object.entries(template.defaultAttributes).forEach(([key, attr]) => { + applyField(`defaultAttributes.${key}.Value`, attr?.Value) + }) } }, [watchedFields.userTemplate, formType]) @@ -825,6 +876,7 @@ const CippAddEditUser = (props) => { value: group.id, addedFields: { groupType: group.groupType, + calculatedGroupType: group.calculatedGroupType, }, })) || [] } @@ -855,7 +907,8 @@ const CippAddEditUser = (props) => { label: userGroups.DisplayName, value: userGroups.id, addedFields: { - groupType: userGroups.calculatedGroupType || userGroups.groupType, + groupType: userGroups.groupType, + calculatedGroupType: userGroups.calculatedGroupType, }, }))} creatable={false} @@ -914,65 +967,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) => { /> + + + + + + + + { /> + + + + + + + + useContext(CippFormPageContext) + const CippFormPage = (props) => { const { title, @@ -114,8 +119,16 @@ const CippFormPage = (props) => { data: values, }) } + const formPageActions = { + submit: formControl.handleSubmit(handleSubmit), + isSubmitting: postCall.isPending, + isValid, + isDirty, + allowResubmit, + } + return ( - <> + { - + ) } diff --git a/src/components/CippIntegrations/CippApiClientManagement.jsx b/src/components/CippIntegrations/CippApiClientManagement.jsx index 3c9f384637ae..19344bd69747 100644 --- a/src/components/CippIntegrations/CippApiClientManagement.jsx +++ b/src/components/CippIntegrations/CippApiClientManagement.jsx @@ -1,4 +1,4 @@ -import { Button, Stack, SvgIcon, Menu, MenuItem, ListItemText, Alert } from "@mui/material"; +import { Button, Stack, SvgIcon, Menu, MenuItem, ListItemText, Alert, Tooltip } from "@mui/material"; import { useState, useEffect, useMemo } from "react"; import isEqual from "lodash/isEqual"; import { useRouter } from "next/router"; @@ -14,7 +14,7 @@ import { } from "@heroicons/react/24/outline"; import { CippApiResults } from "../CippComponents/CippApiResults"; import { CippApiDialog } from "../CippComponents/CippApiDialog"; -import { Create, Key, Save, Sync } from "@mui/icons-material"; +import { Create, InfoOutlined, Key, Save, Sync } from "@mui/icons-material"; import { CippPropertyListCard } from "../CippCards/CippPropertyListCard"; import { CippCopyToClipBoard } from "../CippComponents/CippCopyToClipboard"; import { Box } from "@mui/system"; @@ -319,6 +319,22 @@ const CippApiClientManagement = () => { "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/CippIntegrationFieldMapping.jsx b/src/components/CippIntegrations/CippIntegrationFieldMapping.jsx index 53a88787de19..76162fef0daa 100644 --- a/src/components/CippIntegrations/CippIntegrationFieldMapping.jsx +++ b/src/components/CippIntegrations/CippIntegrationFieldMapping.jsx @@ -42,7 +42,7 @@ const CippIntegrationFieldMapping = () => { var missingMappings = []; fieldMapping?.data?.Mappings?.forEach((mapping) => { const exists = fieldMapping?.data?.IntegrationFields?.some( - (integrationField) => String(integrationField.value) === mapping.IntegrationId + (integrationField) => String(integrationField?.value) === mapping.IntegrationId ); if (exists) { newMappings[mapping.RowKey] = { diff --git a/src/components/CippIntegrations/CippIntegrationSettings.jsx b/src/components/CippIntegrations/CippIntegrationSettings.jsx index d0156df3897a..e5a8cb67cfe4 100644 --- a/src/components/CippIntegrations/CippIntegrationSettings.jsx +++ b/src/components/CippIntegrations/CippIntegrationSettings.jsx @@ -62,7 +62,7 @@ const CippIntegrationSettings = ({ children }) => { {setting?.condition ? ( s.name === `${extension.id}.Enabled`) && !enabled}> - + { ) : ( - + Number(value ?? 0) +const plural = (count, singular, pluralForm) => + `${count} ${count === 1 ? singular : (pluralForm ?? `${singular}s`)}` + +/** + * Grades permission exposure. A tenant-wide claim dominates because it grants access to the whole + * organisation in one entry, regardless of what the site's membership says. + */ +const assessExposure = (summary) => { + let score = 0 + if (nz(summary.broadClaimGrants) > 0) score += 5 + if (nz(summary.externalGrants) > 0) score += 3 + if (nz(summary.directFullControlGrants) > 0) score += 2 + if (nz(summary.uniquePermissionLibraries) > 0) score += 1 + + if (score >= 7) return { level: 'High', severity: 'high' } + if (score >= 3) return { level: 'Medium', severity: 'medium' } + return { level: 'Low', severity: 'low' } +} + +const CLAIM_LABELS = { + Everyone: 'Everyone (includes external users)', + EveryoneExceptExternal: 'Everyone except external users', + AllUsers: 'All Users', +} + +const PermissionsReportDocument = ({ + permissionsData, + brandingSettings, + tenantName, + generatedOn, +}) => { + const brandColor = brandingSettings?.colour || DEFAULT_BRAND_COLOUR + const styles = createReportStyles(brandColor) + const logo = brandingSettings?.logo + const footerLabel = `${tenantName} — SharePoint Permissions` + + const summary = permissionsData?.summary ?? {} + const assignments = permissionsData?.assignments ?? [] + const skippedSites = permissionsData?.skippedSites ?? [] + + const exposure = assessExposure(summary) + const exposureColour = severityColour(exposure.severity) + + // Placeholder rows carry no principal, and Limited Access grants nothing on its own — both are + // excluded from the counts, so they have to be excluded here or the tables contradict them. + const realAssignments = assignments.filter((row) => row.principalId && !row.isSystemManaged) + const broadClaimRows = realAssignments.filter((row) => row.broadClaim) + const externalGrantRows = realAssignments.filter((row) => row.isGuest === true) + const fullControlRows = realAssignments.filter( + (row) => row.permissionLevel === 'Full Control' && row.principalType !== 'SharePoint Group' + ) + const libraryRows = realAssignments.filter((row) => row.scope === 'Library') + + // getAllSites does not always return a display name, so fall back to the URL. + const siteLabel = (row) => row.siteName || row.siteUrl || 'Unnamed site' + const scopeLabel = (row) => + row.scope === 'Library' ? `${siteLabel(row)} / ${row.libraryTitle}` : siteLabel(row) + + return ( + + {/* COVER */} + + + {logo ? : null} + {generatedOn} + + + + Access Review + + Permissions{'\n'} + Report + + + Who is structurally allowed into SharePoint sites and document libraries at {tenantName} + , and where that access reaches further than intended. + + + {tenantName} + + {nz(summary.sitesScanned)} sites · {nz(summary.librariesScanned)} libraries ·{' '} + {nz(summary.totalAssignments)} permission assignments + + + Permission exposure: {exposure.level} + + + + + + Confidential — For Internal Use Only + + + + {/* EXECUTIVE SUMMARY */} + + + + + + Permissions are set by administrators on a site or document library and decide who is + structurally allowed in. They change rarely, which is what makes them worth auditing: a + permission granted for one project stays in place indefinitely, and a permission granted + to the whole organisation looks identical to one granted to a single team until somebody + reads it. This report covers {tenantName}. + + + 0 ? REPORT_COLOURS.danger : undefined, + }, + { + value: nz(summary.externalGrants), + label: 'External Grants', + colour: nz(summary.externalGrants) > 0 ? REPORT_COLOURS.warning : undefined, + }, + { + value: nz(summary.directFullControlGrants), + label: 'Direct Full Control', + colour: + nz(summary.directFullControlGrants) > 0 ? REPORT_COLOURS.warning : undefined, + }, + { + value: nz(summary.uniquePermissionLibraries), + label: 'Detached Libraries', + }, + ]} + /> + + + {exposure.level === 'High' && + 'Content is reachable by people it was never meant for. A tenant-wide grant is present, which opens the content to the entire organisation regardless of who the site membership says should have it — and it is the most common reason material turns up unexpectedly in search results and AI assistant answers. Treat the findings below as immediate remediation work.'} + {exposure.level === 'Medium' && + 'Access extends past the intended audience in places. Each finding below is individually manageable, but each one widens what a single compromised account reaches.'} + {exposure.level === 'Low' && + 'Permissions broadly match what the structure intends. No tenant-wide grants were found. Continue reviewing periodically, particularly after site or library changes.'} + + + + + Scope of This Review + + {nz(summary.sitesScanned)} SharePoint sites and {nz(summary.librariesScanned)} document + libraries were read, producing {nz(summary.totalAssignments)} permission assignments. + Data is taken from the last completed sync, not read live. + + + Permissions are reported as grant paths, not effective access — a group holding a + permission is one entry and its members are not expanded, so a person may hold access + that shows here only via their group. Permissions on individual folders and files are + not enumerated. OneDrive personal sites are out of scope. Access handed out by sharing + link is a separate path, covered by the Sharing Report. + + {skippedSites.length > 0 ? ( + + These sites could not be read on the most recent scan. Where a site was read + successfully before, its earlier results are still shown and are as old as that scan; + a site never read successfully contributes nothing. Either way, an absence of findings + for these sites is not evidence of good configuration. + + ) : null} + + + + + + {/* FINDINGS */} + + + + + Finding 1: Tenant-Wide Grants + + SharePoint offers a handful of special audiences — Everyone, Everyone except external + users, and All Users — that resolve to the whole organisation rather than to named + people. A library carrying one is readable by every employee no matter what the site's + membership says, and it is the single most common cause of data appearing in search + results or AI assistant answers where it was not expected. + + {broadClaimRows.length > 0 ? ( + <> + + Confirm the content is genuinely meant to be organisation-wide. If not, replace the + grant with a specific group — one edit removes access for everyone who was never + meant to have it. + + ({ + location: scopeLabel(row), + audience: CLAIM_LABELS[row.broadClaim] ?? row.broadClaim, + level: row.permissionLevel, + }))} + /> + + ) : ( + + No site or library grants access to Everyone, Everyone except external users, or All + Users. + + )} + + + + Finding 2: External and Guest Access + + Guest accounts holding permissions retain that access until somebody removes it — unlike + a sharing link, nothing expires it. Guests from finished projects are a common source of + standing access nobody is reviewing. + + {externalGrantRows.length > 0 ? ( + <> + + Verify each guest still needs access and that the relationship is current. + + ({ + location: scopeLabel(row), + identity: row.email || row.title || row.loginName, + level: row.permissionLevel, + }))} + /> + + ) : ( + + No guest or external identity holds a permission on a scanned site or library. + + )} + + + + + + {/* FINDINGS CONTINUED */} + + + + + Finding 3: Directly Granted Full Control + + Every site has an Owners group that holds Full Control by design, and that is expected. + Full Control granted straight to a person or a directory group is different: it sits + outside the membership structure, so it is not removed when someone leaves a team and it + is easy to overlook when reviewing who administers a site. + + {fullControlRows.length > 0 ? ( + <> + + Move these into the site's Owners group where the access is legitimate, so + membership changes take effect automatically. + + ({ + location: scopeLabel(row), + principal: row.title || row.email || row.loginName, + type: row.principalType, + }))} + /> + + ) : ( + + No user or directory group holds Full Control outside a site's Owners group. + + )} + + + + Finding 4: Libraries With Their Own Permissions + + A library normally inherits from its site, so managing the site manages everything in + it. A detached library keeps its own permissions and later site-level changes no longer + reach it. That is legitimate when deliberate and a blind spot when not — removing + somebody from the site does not remove them here. + + {nz(summary.uniquePermissionLibraries) > 0 ? ( + + {nz(summary.uniquePermissionLibraries)} of {nz(summary.librariesScanned)} libraries no + longer inherit from their site. Their assignments are listed in the appendix. Review + whether each detachment was intentional and is still needed. + + ) : ( + + Every scanned library takes its permissions from its site, so site-level access + management covers them all. + + )} + + + + + + {/* RECOMMENDATIONS */} + + + + + Priority Actions + + Ordered by how much access each removes relative to the effort involved. + + + + + + Keeping It That Way + + + + + + + {/* APPENDIX */} + + + + + ({ + site: siteLabel(row), + library: row.libraryTitle, + principal: row.title || row.email || row.loginName, + level: row.permissionLevel, + }))} + limit={40} + emptyText="No libraries hold their own permissions." + /> + + + + + + ) +} + +export const PermissionsReportButton = ({ permissionsData, tenantName }) => { + const [dialogOpen, setDialogOpen] = useState(false) + const [generatedOn, setGeneratedOn] = useState('') + const brandingSettings = useSettings()?.customBranding + const hasData = !!permissionsData?.summary + + const handleOpen = () => { + setGeneratedOn( + new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) + ) + setDialogOpen(true) + } + + const documentNode = ( + + ) + + return ( + <> + + + + + + + setDialogOpen(false)} + maxWidth="lg" + fullWidth + PaperProps={{ sx: { height: '90vh' } }} + > + + + + Permissions Report Preview + + setDialogOpen(false)} size="small"> + + + + + + {dialogOpen && ( + + {documentNode} + + )} + + + + + {({ loading }) => ( + + )} + + + + + ) +} + +export default PermissionsReportButton diff --git a/src/components/CippPdf/SharingReportButton.jsx b/src/components/CippPdf/SharingReportButton.jsx new file mode 100644 index 000000000000..0bac0756bc86 --- /dev/null +++ b/src/components/CippPdf/SharingReportButton.jsx @@ -0,0 +1,551 @@ +import { useState } from 'react' +import { + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Tooltip, + Typography, +} from '@mui/material' +import { Close, Download, PictureAsPdf } from '@mui/icons-material' +import { Document, Page, Text, View, Image, PDFViewer, PDFDownloadLink } from '@react-pdf/renderer' +import { createReportStyles, DEFAULT_BRAND_COLOUR, REPORT_COLOURS } from './reportPdfStyles' +import { + AlertBox, + BulletList, + ClearBox, + DataTable, + InfoBox, + PageFooter, + PageHeader, + StatRow, + severityColour, +} from './reportPdfPrimitives' +import { useSettings } from '../../hooks/use-settings' + +const nz = (value) => Number(value ?? 0) +const joinList = (value) => (Array.isArray(value) ? value.join(', ') : (value ?? '')) +const plural = (count, singular, pluralForm) => + `${count} ${count === 1 ? singular : (pluralForm ?? `${singular}s`)}` + +/** + * Grades sharing exposure. An anonymous link that also allows editing dominates because it is the + * only combination that lets an unidentified person change content, with no record of who did it. + */ +const assessExposure = (summary) => { + let score = 0 + if (nz(summary.anonymousEditLinks) > 0) score += 5 + if (nz(summary.neverExpiringAnonymous) > 0) score += 3 + if (nz(summary.anonymousLinks) > 0) score += 2 + if (nz(summary.folderShares) > 0) score += 2 + if (nz(summary.externalLinks) > 0) score += 1 + + if (score >= 7) return { level: 'High', severity: 'high' } + if (score >= 3) return { level: 'Medium', severity: 'medium' } + return { level: 'Low', severity: 'low' } +} + +const SharingReportDocument = ({ sharingData, brandingSettings, tenantName, generatedOn }) => { + const brandColor = brandingSettings?.colour || DEFAULT_BRAND_COLOUR + const styles = createReportStyles(brandColor) + const logo = brandingSettings?.logo + const footerLabel = `${tenantName} — SharePoint & OneDrive Sharing` + + const summary = sharingData?.summary ?? {} + const links = sharingData?.links ?? [] + const topRecipients = sharingData?.topRecipients ?? [] + const topLibraries = sharingData?.topLibraries ?? [] + + const exposure = assessExposure(summary) + const exposureColour = severityColour(exposure.severity) + + const canEdit = (row) => + joinList(row.roles).includes('write') || joinList(row.roles).includes('owner') + const anonEditRows = links.filter((row) => row.classification === 'Anonymous' && canEdit(row)) + const neverExpiringRows = links.filter( + (row) => row.classification === 'Anonymous' && !row.expirationDateTime + ) + const folderShareRows = links.filter( + (row) => row.itemType === 'Folder' && ['Anonymous', 'External'].includes(row.classification) + ) + const externalRows = links.filter((row) => row.classification === 'External') + + const locationOf = (row) => + `${row.siteName || row.siteUrl || 'Unknown site'}${row.driveName ? ` / ${row.driveName}` : ''}` + const expiryOf = (row) => + row.expirationDateTime ? new Date(row.expirationDateTime).toLocaleDateString() : 'Never' + + return ( + + {/* COVER */} + + + {logo ? : null} + {generatedOn} + + + + Data Sharing Review + + Sharing{'\n'} + Report + + + What has been shared out of SharePoint and OneDrive at {tenantName}, who it reaches, and + which of those shares are worth acting on. + + + {tenantName} + + {nz(summary.totalLinks)} sharing links · {nz(summary.itemsShared)} items ·{' '} + {nz(summary.externalRecipients)} external recipients + + + Sharing exposure: {exposure.level} + + + + + + Confidential — For Internal Use Only + + + + {/* EXECUTIVE SUMMARY */} + + + + + + Sharing links are created by users on individual files and folders. They hand out access + outside the permission structure an administrator sets on a site or library, they + accumulate quietly as people work, and nothing prompts anyone to review them. This + report covers what exists today across SharePoint and OneDrive in{' '} + {tenantName}. + + + 0 ? REPORT_COLOURS.danger : undefined, + }, + { + value: nz(summary.neverExpiringAnonymous), + label: 'Anonymous, No Expiry', + colour: nz(summary.neverExpiringAnonymous) > 0 ? REPORT_COLOURS.danger : undefined, + }, + { + value: nz(summary.folderShares), + label: 'Shared Folders', + colour: nz(summary.folderShares) > 0 ? REPORT_COLOURS.warning : undefined, + }, + { + value: nz(summary.externalRecipients), + label: 'External Recipients', + colour: nz(summary.externalRecipients) > 0 ? REPORT_COLOURS.warning : undefined, + }, + ]} + /> + + + {exposure.level === 'High' && + 'Content is reachable by people who cannot be identified. Anonymous links work for anyone holding them, with no sign-in and no record of use — and where those links also allow editing, changes are attributed to nobody. Treat the findings below as immediate remediation work.'} + {exposure.level === 'Medium' && + 'Sharing extends beyond the intended audience in places. Each finding below is individually manageable, but every open link widens what a single forwarded message can expose.'} + {exposure.level === 'Low' && + 'No high-risk sharing was found. Links are scoped and time-bounded. Continue reviewing periodically, since sharing accumulates as projects come and go.'} + + + + + Scope of This Review + + {nz(summary.totalLinks)} sharing links and external shares across{' '} + {nz(summary.sharePointSites)} SharePoint sites, {nz(summary.teamsSites)} Teams-connected + sites and {nz(summary.oneDriveAccounts)} OneDrive accounts, covering{' '} + {nz(summary.itemsShared)} distinct shared items. Data is taken from the last completed + sync, not read live. + + + This report covers sharing links only. Permissions granted on a site or document library + are a separate access path, governed differently, and are covered by the Permissions + Report. A clean result here does not mean access is restricted — it means nothing has + been shared out by link. + + + + + + + {/* FINDINGS */} + + + + + Finding 1: Anonymous Links That Allow Editing + + An anonymous link works for anyone who holds it — no sign-in, no record of who used it. + When that link also grants editing, anyone it has been forwarded to can change or delete + the content, and the change is attributed to nobody. This is the only combination that + allows untraceable modification. + + {anonEditRows.length > 0 ? ( + <> + + Revoke these, or downgrade them to view-only where the sharing is still needed. + + ({ + item: row.fileName, + location: locationOf(row), + expires: expiryOf(row), + }))} + /> + + ) : ( + + No anonymous link grants write access. + + )} + + + + Finding 2: Anonymous Links That Never Expire + + A link with no expiry date stays live indefinitely, long after the reason for sharing + has passed. Expiry is the only control that withdraws this access without somebody + remembering to do it. + + {neverExpiringRows.length > 0 ? ( + <> + + Set a tenant-level default expiry so this cannot recur, then revoke the existing + links that are no longer needed. + + ({ + item: row.fileName, + location: locationOf(row), + roles: joinList(row.roles), + }))} + /> + + ) : ( + + Every anonymous link has an expiry date set. + + )} + + + + + + {/* FINDINGS CONTINUED */} + + + + + Finding 3: Shared Folders + + Sharing a folder shares everything inside it, including anything added later. The + recipient's access grows over time without anyone re-approving it, which is the main way + a small share quietly becomes a large one. + + {folderShareRows.length > 0 ? ( + <> + + Check what each folder holds now, not what it held when it was shared. + + ({ + item: row.fileName, + location: locationOf(row), + audience: row.classification, + }))} + /> + + ) : ( + + External and anonymous shares point at individual files rather than folders. + + )} + + + + Finding 4: External Recipients + + Every external recipient is a person outside the organisation holding access that was + granted individually, usually for a specific piece of work. Nothing withdraws it when + that work ends. + + {topRecipients.length > 0 ? ( + <> + + {plural(nz(summary.externalRecipients), 'external identity', 'external identities')}{' '} + hold shared content, across {plural(externalRows.length, 'share')}. The most + frequent are listed below. + + ({ + recipient: row.recipient, + shares: String(row.links), + }))} + limit={15} + /> + + ) : ( + + Nothing has been shared with an identity outside the organisation. + + )} + + + {topLibraries.length > 0 && ( + + Where Sharing Concentrates + + The libraries below account for the most sharing links. Concentration is not a problem + in itself, but it shows where a review will have the most effect. + + ({ + library: row.library, + links: String(row.links), + }))} + limit={10} + /> + + )} + + + + + {/* RECOMMENDATIONS */} + + + + + Priority Actions + + Ordered by how much exposure each removes relative to the effort involved. + + + + + + Keeping It That Way + + + + + + + ) +} + +export const SharingReportButton = ({ sharingData, tenantName }) => { + const [dialogOpen, setDialogOpen] = useState(false) + const [generatedOn, setGeneratedOn] = useState('') + const brandingSettings = useSettings()?.customBranding + const hasData = !!sharingData?.summary + + const handleOpen = () => { + setGeneratedOn( + new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) + ) + setDialogOpen(true) + } + + const documentNode = ( + + ) + + return ( + <> + + + + + + + setDialogOpen(false)} + maxWidth="lg" + fullWidth + PaperProps={{ sx: { height: '90vh' } }} + > + + + + Sharing Report Preview + + setDialogOpen(false)} size="small"> + + + + + + {dialogOpen && ( + + {documentNode} + + )} + + + + + {({ loading }) => ( + + )} + + + + + ) +} + +export default SharingReportButton diff --git a/src/components/CippPdf/reportPdfPrimitives.jsx b/src/components/CippPdf/reportPdfPrimitives.jsx new file mode 100644 index 000000000000..73bb14179203 --- /dev/null +++ b/src/components/CippPdf/reportPdfPrimitives.jsx @@ -0,0 +1,135 @@ +import { Text, View, Image } from '@react-pdf/renderer' +import { REPORT_COLOURS } from './reportPdfStyles' + +// Shared building blocks for CIPP's client-facing PDF reports. Every one takes the `styles` object +// from createReportStyles so a report can restyle without forking the markup. + +export const PageHeader = ({ styles, title, subtitle, logo }) => ( + + + {title} + {subtitle ? {subtitle} : null} + + {logo ? : null} + +) + +export const PageFooter = ({ styles, label }) => ( + + {label} + `Page ${pageNumber} of ${totalPages}`} + /> + +) + +// A row of stat cards. Pass at most four — beyond that they get too narrow to read. +export const StatRow = ({ styles, stats }) => ( + + {stats.map((stat) => ( + + + {stat.value} + + {stat.label} + + ))} + +) + +export const InfoBox = ({ styles, title, children }) => ( + + {title ? {title} : null} + {children} + +) + +export const AlertBox = ({ styles, title, colour, children }) => ( + + {title} + {children} + +) + +// The all-clear counterpart to AlertBox, for a check that found nothing. +export const ClearBox = ({ styles, title, children }) => ( + + {title} + {children} + +) + +export const BulletList = ({ styles, items }) => ( + + {items.map((item, index) => ( + + {item.marker ?? '•'} + + {item.label ? {item.label} : null} + {item.text} + + + ))} + +) + +/** + * Fixed-width data table. `columns` is [{ header, key, width }] where width is a flex ratio. + * Rows beyond `limit` are dropped with a note rather than running to hundreds of pages — the + * full set is always available from the table export on the page itself. + */ +export const DataTable = ({ + styles, + columns, + rows, + limit = 25, + emptyText = 'Nothing to report.', +}) => { + const shown = rows.slice(0, limit) + const hidden = rows.length - shown.length + + return ( + <> + + + {columns.map((column) => ( + + {column.header} + + ))} + + {shown.length === 0 ? ( + {emptyText} + ) : ( + shown.map((row, index) => ( + + {columns.map((column) => ( + + {row[column.key] ?? ''} + + ))} + + )) + )} + + {hidden > 0 ? ( + + … and {hidden} more. Export the table from the report page for the full list. + + ) : null} + + ) +} + +// Shared severity vocabulary so every report grades findings the same way. +export const severityColour = (severity) => { + switch (severity) { + case 'high': + return REPORT_COLOURS.danger + case 'medium': + return REPORT_COLOURS.warning + default: + return REPORT_COLOURS.success + } +} diff --git a/src/components/CippPdf/reportPdfStyles.js b/src/components/CippPdf/reportPdfStyles.js new file mode 100644 index 000000000000..6eeff139277e --- /dev/null +++ b/src/components/CippPdf/reportPdfStyles.js @@ -0,0 +1,245 @@ +import { StyleSheet } from '@react-pdf/renderer' + +// The @react-pdf/renderer style system shared by CIPP's client-facing PDF reports: a cover page, +// branded content pages, stat cards, callout boxes, data tables and a footer with page numbers. +// +// The existing report components (BECRemediationReportButton, ExecutiveReportButton, +// ShadowAIReportButton, ReportBuilderPDF) each carry their own copy of roughly this sheet. They +// are deliberately left alone - this module exists so new reports stop adding to that, and so +// those four can adopt it later without a risky big-bang refactor. + +export const DEFAULT_BRAND_COLOUR = '#F77F00' + +// Status colours, matching the severity language used across the reports. +export const REPORT_COLOURS = { + danger: '#742A2A', + dangerBg: '#FED7D7', + warning: '#744210', + warningBg: '#FEEBC8', + success: '#22543D', + successBg: '#C6F6D5', + info: '#2C5282', + infoBg: '#BEE3F8', + ink: '#1A202C', + body: '#2D3748', + muted: '#4A5568', + faint: '#718096', + line: '#E2E8F0', + panel: '#F7FAFC', +} + +export const createReportStyles = (brandColor = DEFAULT_BRAND_COLOUR) => + StyleSheet.create({ + page: { + flexDirection: 'column', + backgroundColor: '#FFFFFF', + fontFamily: 'Helvetica', + fontSize: 10, + lineHeight: 1.4, + color: REPORT_COLOURS.body, + padding: 40, + paddingBottom: 60, + }, + + // COVER + 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: REPORT_COLOURS.ink, + lineHeight: 1.1, + marginBottom: 20, + letterSpacing: -1, + }, + titleAccent: { color: brandColor }, + subtitle: { + fontSize: 14, + color: '#000000', + lineHeight: 1.5, + marginBottom: 40, + maxWidth: 400, + }, + coverMetaLabel: { fontSize: 18, fontWeight: 'bold', color: '#000000', marginBottom: 8 }, + coverMetavalue: { fontSize: 12, color: '#333333', marginBottom: 4 }, + 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: REPORT_COLOURS.ink, marginBottom: 8 }, + pageSubtitle: { fontSize: 11, color: REPORT_COLOURS.muted }, + + section: { marginBottom: 24, pageBreakInside: 'avoid', breakInside: 'avoid' }, + sectionTitle: { fontSize: 14, fontWeight: 'bold', color: brandColor, marginBottom: 12 }, + bodyText: { + fontSize: 9, + color: REPORT_COLOURS.body, + lineHeight: 1.5, + marginBottom: 12, + textAlign: 'justify', + }, + bold: { fontWeight: 'bold' }, + + bulletList: { marginLeft: 12, marginBottom: 12 }, + bulletItem: { flexDirection: 'row', alignItems: 'flex-start', marginBottom: 6 }, + bulletPoint: { + fontSize: 8, + color: brandColor, + marginRight: 6, + fontWeight: 'bold', + marginTop: 1, + }, + bulletText: { fontSize: 9, color: REPORT_COLOURS.body, lineHeight: 1.4, flex: 1 }, + + // CALLOUTS + alertBox: { + backgroundColor: '#FFF5F5', + border: `2px solid ${brandColor}`, + borderRadius: 6, + padding: 12, + marginBottom: 16, + }, + alertTitle: { fontSize: 11, fontWeight: 'bold', color: brandColor, marginBottom: 6 }, + alertText: { fontSize: 9, color: REPORT_COLOURS.body, lineHeight: 1.4 }, + + infoBox: { + backgroundColor: REPORT_COLOURS.panel, + border: `1px solid ${REPORT_COLOURS.line}`, + borderLeft: `4px solid ${brandColor}`, + borderRadius: 4, + padding: 12, + marginBottom: 12, + }, + infoTitle: { fontSize: 10, fontWeight: 'bold', color: REPORT_COLOURS.body, marginBottom: 6 }, + infoText: { fontSize: 8, color: REPORT_COLOURS.muted, lineHeight: 1.4 }, + okBox: { backgroundColor: '#F0FDF4' }, + okTitle: { color: REPORT_COLOURS.success }, + + // STATS + statsGrid: { + flexDirection: 'row', + gap: 12, + marginBottom: 20, + pageBreakInside: 'avoid', + breakInside: 'avoid', + }, + statCard: { + flex: 1, + backgroundColor: '#FFFFFF', + border: `1px solid ${REPORT_COLOURS.line}`, + borderRadius: 6, + padding: 16, + alignItems: 'center', + borderTop: `3px solid ${brandColor}`, + }, + statNumber: { fontSize: 20, fontWeight: 'bold', color: brandColor, marginBottom: 4 }, + statLabel: { + fontSize: 7, + color: REPORT_COLOURS.muted, + textTransform: 'uppercase', + letterSpacing: 0.5, + textAlign: 'center', + fontWeight: 'bold', + }, + + // TABLES + table: { + border: `1px solid ${REPORT_COLOURS.line}`, + borderRadius: 6, + overflow: 'hidden', + marginBottom: 16, + }, + tableHeader: { + flexDirection: 'row', + backgroundColor: brandColor, + paddingVertical: 10, + paddingHorizontal: 12, + }, + tableHeaderCell: { + fontSize: 7, + fontWeight: 'bold', + color: '#FFFFFF', + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + tableRow: { + flexDirection: 'row', + borderBottomWidth: 1, + borderBottomColor: REPORT_COLOURS.panel, + paddingVertical: 8, + paddingHorizontal: 12, + alignItems: 'flex-start', + }, + tableCell: { fontSize: 8, color: REPORT_COLOURS.body, lineHeight: 1.3 }, + tableEmpty: { fontSize: 8, color: REPORT_COLOURS.faint, fontStyle: 'italic', padding: 12 }, + truncationNote: { + fontSize: 8, + color: REPORT_COLOURS.faint, + fontStyle: 'italic', + marginLeft: 12, + marginBottom: 12, + }, + + // FOOTER + footer: { + position: 'absolute', + bottom: 20, + left: 40, + right: 40, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + borderTop: `1px solid ${REPORT_COLOURS.line}`, + paddingTop: 8, + }, + footerText: { fontSize: 7, color: REPORT_COLOURS.faint }, + pageNumber: { fontSize: 7, color: REPORT_COLOURS.faint, fontWeight: 'bold' }, + }) diff --git a/src/components/CippSettings/CippAppServiceDomains.jsx b/src/components/CippSettings/CippAppServiceDomains.jsx new file mode 100644 index 000000000000..832d97bbc088 --- /dev/null +++ b/src/components/CippSettings/CippAppServiceDomains.jsx @@ -0,0 +1,613 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Alert, + Box, + Button, + CardContent, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + Skeleton, + Stack, + Step, + StepLabel, + Stepper, + SvgIcon, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import { Grid } from "@mui/system"; +import { + CheckCircle, + Cancel, + HelpOutline, + Lock, + LockOpen, + Refresh, +} from "@mui/icons-material"; +import { PlusIcon, TrashIcon, WrenchScrewdriverIcon } from "@heroicons/react/24/outline"; +import { CippDataTable } from "../CippTable/CippDataTable"; +import CippButtonCard from "../CippCards/CippButtonCard"; +import { CippApiResults } from "../CippComponents/CippApiResults"; +import { CippCopyToClipBoard } from "../CippComponents/CippCopyToClipboard"; +import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; + +const LIST_QUERY_KEY = "AppServiceDomains"; + +const sslStateLabel = (state) => { + switch (state) { + case "SniEnabled": + return "Secured (SNI SSL)"; + case "IpBasedEnabled": + return "Secured (IP SSL)"; + default: + return "Not secured"; + } +}; + +// Client-side mirror of the backend Get-DomainRecordPlan so the required DNS records render the +// instant a hostname is typed — the live CheckDns call then overlays the verification status. +const computeRecordPlan = (hostname, siteInfo) => { + const host = (hostname || "").trim().toLowerCase(); + const isWildcard = host.startsWith("*."); + const base = isWildcard ? host.slice(2) : host; + const labels = base.split(".").filter(Boolean); + const isApex = !isWildcard && labels.length <= 2; + const asuidHost = isWildcard ? `asuid.${base}` : `asuid.${host}`; + + return { + host, + isWildcard, + isApex, + recommendedType: isApex ? "A" : "CNAME", + records: [ + { + purpose: "Ownership", + type: "TXT", + host: asuidHost, + value: siteInfo?.CustomDomainVerificationId ?? "", + }, + isApex + ? { purpose: "Alias", type: "A", host, value: siteInfo?.InboundIpAddress ?? "" } + : { purpose: "Alias", type: "CNAME", host, value: siteInfo?.DefaultHostName ?? "" }, + ], + }; +}; + +const HOSTNAME_REGEX = /^(\*\.)?([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i; + +const InfoRow = ({ label, value, copy = true }) => ( + + + + {label} + + + + + + {value || "—"} + + {copy && value ? : null} + + + +); + +const VerifyIcon = ({ state }) => { + if (state === true) { + return ( + + + + ); + } + if (state === false) { + return ( + + + + ); + } + return ( + + + + ); +}; + +const anyPending = (mutations) => mutations.some((m) => m?.isPending); + +// ── The add / fix domain wizard ───────────────────────────────────────────── +const DomainWizard = ({ open, onClose, siteInfo, initialDomain }) => { + const managing = Boolean(initialDomain); + const [activeStep, setActiveStep] = useState(0); + const [hostname, setHostname] = useState(""); + const [bindingDone, setBindingDone] = useState(false); + const [certDone, setCertDone] = useState(false); + const [dnsResult, setDnsResult] = useState(null); + + const dnsCheck = ApiPostCall({ onResult: (body) => setDnsResult(body?.Results ?? null) }); + const bindingAction = ApiPostCall({ + relatedQueryKeys: [LIST_QUERY_KEY], + onResult: () => { + setBindingDone(true); + setActiveStep(2); + }, + }); + const certAction = ApiPostCall({ + relatedQueryKeys: [LIST_QUERY_KEY], + onResult: () => setCertDone(true), + }); + + // (Re)initialize whenever the dialog opens so a reopened domain resumes at the right step. + useEffect(() => { + if (!open) return; + dnsCheck.reset(); + bindingAction.reset(); + certAction.reset(); + setDnsResult(null); + if (managing) { + setHostname(initialDomain.Hostname); + setBindingDone(true); + setCertDone(Boolean(initialDomain.Secured)); + setActiveStep(2); + } else { + setHostname(""); + setBindingDone(false); + setCertDone(false); + setActiveStep(0); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const plan = useMemo(() => computeRecordPlan(hostname, siteInfo), [hostname, siteInfo]); + const isWildcard = plan.isWildcard; + const hostnameValid = HOSTNAME_REGEX.test(hostname.trim()); + + // Overlay live verification (from CheckDns) onto the computed record list by matching purpose. + const recordStatus = useMemo(() => { + const map = {}; + (dnsResult?.Records ?? []).forEach((r) => { + map[r.Purpose] = r.Verified; + }); + return map; + }, [dnsResult]); + + const ownershipVerified = dnsResult?.OwnershipVerified ?? false; + const aliasVerified = dnsResult?.AliasVerified ?? false; + + const runDnsCheck = () => { + dnsCheck.mutate({ + url: "/api/ExecAppServiceDomains", + data: { Action: "CheckDns", Hostname: hostname.trim() }, + }); + }; + + const runAddBinding = () => { + bindingAction.mutate({ + url: "/api/ExecAppServiceDomains", + data: { Action: "AddBinding", Hostname: hostname.trim() }, + }); + }; + + const runAddCertificate = () => { + certAction.mutate({ + url: "/api/ExecAppServiceDomains", + data: { Action: "AddCertificate", Hostname: hostname.trim() }, + }); + }; + + const steps = ["Verify domain ownership", "Create hostname binding", "Enable HTTPS certificate"]; + + return ( + + + {managing ? `Manage domain — ${initialDomain?.Hostname}` : "Add custom domain"} + + + + {steps.map((label, idx) => ( + + {label} + + ))} + + + {/* Step 0 — DNS ownership + alias records */} + {activeStep === 0 && ( + + { + setHostname(e.target.value); + setDnsResult(null); + }} + error={hostname.length > 0 && !hostnameValid} + helperText={ + hostname.length > 0 && !hostnameValid + ? "Enter a fully qualified domain name (e.g. portal.contoso.com)." + : "The fully qualified domain you want CIPP to answer on." + } + /> + + {hostnameValid && ( + <> + + Create the following records at your DNS provider, then click{" "} + Check DNS. The {plan.recommendedType} alias record + is recommended for this domain type; Azure also accepts the other alias type. + + + + + Purpose + Type + Host / Name + Value + Status + + + + {plan.records.map((r) => ( + + {r.purpose} + + + + + {r.host} + + + + {r.value} + {r.value ? : null} + + + + + + ))} + +
    + {isWildcard && ( + + Wildcard domains verify by ownership only; the alias is validated by Azure when the + binding is created. Note: App Service Managed Certificates do not support wildcard + domains — you will need to upload your own certificate for HTTPS. + + )} + {dnsResult && !ownershipVerified && ( + + The ownership TXT record hasn't propagated yet. DNS changes can take a few minutes.{" "} + {dnsResult.AliasDetail} + + )} + {dnsResult && ownershipVerified && !aliasVerified && !isWildcard && ( + + Ownership is verified. The alias record isn't visible yet — this is expected if the + record is proxied (e.g. Cloudflare orange-cloud). You can continue; Azure will make + the final check when the binding is created. + + )} + {dnsCheck.isError && ( + + {dnsCheck.error?.response?.data?.Results || + "Failed to run the DNS check. Please try again."} + + )} + + )} +
    + )} + + {/* Step 1 — hostname binding */} + {activeStep === 1 && ( + + + Create the hostname binding on the App Service for {hostname}. Azure + re-validates the DNS records during this step. + + {bindingDone ? ( + }> + The hostname binding for {hostname} exists. + + ) : null} + + + )} + + {/* Step 2 — managed certificate + SNI binding */} + {activeStep === 2 && ( + + {certDone ? ( + }> + {hostname} is fully configured and secured with a managed certificate. + + ) : isWildcard ? ( + + App Service Managed Certificates don't support wildcard domains. Upload your own + certificate and binding from the Azure Portal to secure {hostname}. + + ) : ( + <> + + Provision a free App Service Managed Certificate for {hostname} and + enable the SNI SSL binding. This can take a minute or two. + + + If the domain's alias is proxied through a CDN (e.g. Cloudflare orange-cloud), + temporarily set it to DNS-only while the certificate is issued, then re-enable the + proxy afterwards. Certificate issuance validates the domain directly. + + + )} + + + )} +
    + + + + {activeStep > 0 && !managing && ( + + )} + + + + + {activeStep === 0 && ( + <> + + + + )} + + {activeStep === 1 && ( + + )} + + {activeStep === 2 && !certDone && !isWildcard && ( + + )} + + {activeStep === 2 && (certDone || isWildcard) && ( + + )} + + +
    + ); +}; + +export const CippAppServiceDomains = () => { + const [wizardOpen, setWizardOpen] = useState(false); + const [managingDomain, setManagingDomain] = useState(null); + + const domainsQuery = ApiGetCall({ + url: "/api/ExecAppServiceDomains", + data: { Action: "List" }, + queryKey: LIST_QUERY_KEY, + }); + + const siteInfo = domainsQuery.data?.Results; + const domains = useMemo(() => { + const list = siteInfo?.Domains ?? []; + return list.map((d) => ({ + ...d, + Status: d.IsDefault ? "Default (Azure-managed)" : sslStateLabel(d.SslState), + })); + }, [siteInfo]); + + const openAddWizard = () => { + setManagingDomain(null); + setWizardOpen(true); + }; + + const openManageWizard = (row) => { + setManagingDomain(row); + setWizardOpen(true); + }; + + const actions = [ + { + label: "Manage / Fix", + icon: ( + + + + ), + noConfirm: true, + customFunction: (row) => openManageWizard(row), + condition: (row) => !row.IsDefault, + }, + { + label: "Remove domain", + icon: ( + + + + ), + color: "error.main", + confirmText: + "Remove the custom domain '[Hostname]' from the CIPP App Service? Any managed certificate for it will also be removed. The default *.azurewebsites.net hostname is unaffected.", + url: "/api/ExecAppServiceDomains", + type: "POST", + data: { Action: "Remove", Hostname: "Hostname" }, + relatedQueryKeys: [LIST_QUERY_KEY], + condition: (row) => !row.IsDefault, + }, + ]; + + const offCanvas = { + children: (row) => ( + + + + Hostname + + + {row.Hostname} + + + + + {row.Secured ? : } + {sslStateLabel(row.SslState)} + + {row.HostNameType && ( + + Binding type: {row.HostNameType} + + )} + {row.CertThumbprint && ( + <> + + + + Certificate thumbprint + + + {row.CertThumbprint} + + + {row.CertExpiration && ( + + Expires: {new Date(row.CertExpiration).toLocaleString()} + + )} + + )} + + ), + }; + + return ( + + + + Map custom domains to the App Service that hosts this CIPP instance. Each domain needs a DNS + ownership record and an alias record, a hostname binding, and (optionally) a free managed + TLS certificate — the wizard walks through all three and can be reopened at any time to + finish or fix a domain. The default *.azurewebsites.net hostname always remains + available. + + + + + + + {domainsQuery.isLoading ? ( + + + + + + ) : domainsQuery.isError ? ( + + Could not load App Service details. Ensure the managed identity has access to the + resource group. + + ) : ( + + + + + + + Use the default hostname as the CNAME target for subdomains, the inbound IP as the A + record for apex domains, and the verification ID as the asuid TXT value. + + + )} + + + + + + + + + } + onClick={openAddWizard} + > + Add Custom Domain + + } + /> + + + setWizardOpen(false)} + siteInfo={siteInfo} + initialDomain={managingDomain} + /> + + ); +}; + +export default CippAppServiceDomains; diff --git a/src/components/CippSettings/CippBrandingSettings.jsx b/src/components/CippSettings/CippBrandingSettings.jsx index b0e0f747ef35..34c4571aeac6 100644 --- a/src/components/CippSettings/CippBrandingSettings.jsx +++ b/src/components/CippSettings/CippBrandingSettings.jsx @@ -177,33 +177,13 @@ const CippBrandingSettings = () => { 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/CippContainerManagement.jsx b/src/components/CippSettings/CippContainerManagement.jsx index 37daf6aa3e45..e88bf90b9ff5 100644 --- a/src/components/CippSettings/CippContainerManagement.jsx +++ b/src/components/CippSettings/CippContainerManagement.jsx @@ -45,7 +45,7 @@ export const CippContainerManagement = () => { const updateSettingsForm = useForm({ mode: "onChange", - defaultValues: { CheckInterval: null, AutoUpdate: false, CheckTime: null }, + defaultValues: { CheckInterval: null, AutoUpdate: true, CheckTime: null }, }); const containerStatus = ApiGetCall({ @@ -149,6 +149,13 @@ export const CippContainerManagement = () => { return digest.length > 20 ? `${digest.slice(0, 20)}…` : digest; }; + const formatUtcDate = (value) => { + if (!value || value === "unknown") return "unknown"; + const date = new Date(value); + if (isNaN(date.getTime())) return value; + return `${date.toISOString().slice(0, 16).replace("T", " ")} UTC`; + }; + return ( @@ -205,6 +212,17 @@ export const CippContainerManagement = () => { + + + Image Built (UTC) + + + + + {formatUtcDate(data?.BuildDate)} + + + Commit SHA @@ -216,25 +234,6 @@ export const CippContainerManagement = () => { - {updateSettings?.RunningDigest && ( - <> - - - Container Digest - - - - - {truncateDigest(updateSettings.RunningDigest)} - - - - )} - {data?.CurrentImage && data.CurrentImage !== "unknown" && ( <> @@ -278,7 +277,8 @@ export const CippContainerManagement = () => { Configure automatic update checking. CIPP will query the container registry for a new - image digest and optionally restart the container to apply the update. + image digest and optionally restart the container to apply the update. By default, + CIPP checks every hour and auto-restarts at the preferred time of 23:00. NOTE: If the container restarts for any reason the latest image version for your update channel will be pulled regardless @@ -324,36 +324,64 @@ export const CippContainerManagement = () => { )} - {updateSettings?.RunningDigest && updateSettings?.RemoteDigest && ( + {(updateSettings?.RunningVersion || updateSettings?.RemoteVersion) && ( - Running Digest + Running Version - - {truncateDigest(updateSettings.RunningDigest)} + + {updateSettings.RunningVersion || "unknown"} - Remote Digest + Remote Version - - {truncateDigest(updateSettings.RemoteDigest)} + + {updateSettings.RemoteVersion || "unknown"} + {updateSettings?.RemoteBuildDate && ( + <> + + + Remote Built (UTC) + + + + + {formatUtcDate(updateSettings.RemoteBuildDate)} + + + + )} + {updateSettings?.RemoteDigest && ( + <> + + + Remote Digest + + + + + {truncateDigest(updateSettings.RemoteDigest)} + + + + )} )} diff --git a/src/components/CippSettings/CippGDAPResults.jsx b/src/components/CippSettings/CippGDAPResults.jsx index 46d505a4535d..306c7451eb32 100644 --- a/src/components/CippSettings/CippGDAPResults.jsx +++ b/src/components/CippSettings/CippGDAPResults.jsx @@ -1,15 +1,34 @@ -import { Alert, List, ListItem, Skeleton, SvgIcon, Typography } from "@mui/material"; +import { Alert, Button, List, ListItem, Skeleton, SvgIcon, Typography } from "@mui/material"; import { Cancel, CheckCircle, Warning } from "@mui/icons-material"; import { CippPropertyList } from "../CippComponents/CippPropertyList"; -import { XMarkIcon } from "@heroicons/react/24/outline"; +import { WrenchIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { CippOffCanvas } from "../CippComponents/CippOffCanvas"; import { CippDataTable } from "../CippTable/CippDataTable"; +import { ApiPostCall } from "../../api/ApiCall"; +import { CippApiResults } from "../CippComponents/CippApiResults"; import { useEffect, useState } from "react"; export const CippGDAPResults = (props) => { const { executeCheck, offcanvasVisible, setOffcanvasVisible, importReport, setCardIcon } = props; const [results, setResults] = useState({}); + const repairRoleMappings = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: ["ExecAccessChecks-GDAP"], + }); + + const handleRepairRoleMappings = () => { + repairRoleMappings.mutate({ + url: "/api/ExecGDAPRepairRoleMappings", + data: {}, + queryKey: "RepairGDAPRoleMappings", + }); + }; + + const hasRoleMappingIssues = results?.Results?.RoleMappingResults?.some( + (item) => item?.Status === "Stale" || item?.Status === "Missing", + ); + useEffect(() => { if (importReport) { setResults(importReport); @@ -19,7 +38,11 @@ export const CippGDAPResults = (props) => { }, [executeCheck, importReport]); useEffect(() => { - if (results?.Results?.GDAPIssues?.length > 0 || results?.Results?.MissingGroups?.length > 0) { + if ( + results?.Results?.GDAPIssues?.length > 0 || + results?.Results?.MissingGroups?.length > 0 || + hasRoleMappingIssues + ) { setCardIcon(); } else { setCardIcon(); @@ -77,6 +100,15 @@ export const CippGDAPResults = (props) => { successMessage: "No Global Admin relationships found", failureMessage: "Global Admin relationships found", }, + { + resultProperty: "RoleMappingResults", + matchProperty: "Status", + match: "^(Stale|Missing)$", + count: 0, + successMessage: "All GDAP role mappings reference existing security groups", + failureMessage: + "One or more GDAP role mappings reference stale or missing security groups. Click Details to repair.", + }, ]; const propertyItems = [ @@ -154,13 +186,16 @@ export const CippGDAPResults = (props) => { }} extendedInfo={[]} > - {results?.Results?.GDAPIssues?.length > 0 && ( + {results?.Results?.GDAPIssues?.filter((issue) => issue.Category !== "RoleMapping") + .length > 0 && ( <> issue.Category !== "RoleMapping", + )} simpleColumns={["Tenant", "Type", "Issue", "Link"]} /> @@ -178,6 +213,37 @@ export const CippGDAPResults = (props) => { )} + {results?.Results?.RoleMappingResults?.length > 0 && ( + <> + + + + + } + > + Repair Role Mappings + + ) + } + data={results?.Results?.RoleMappingResults} + simpleColumns={["RoleName", "GroupName", "GroupId", "Status", "Message"]} + /> + + )} + {results?.Results?.Memberships?.filter( (membership) => membership?.["@odata.type"] === "#microsoft.graph.group", ).length > 0 && ( diff --git a/src/components/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 284579c2b60d..f140fab281af 100644 --- a/src/components/CippTable/CippDataTable.js +++ b/src/components/CippTable/CippDataTable.js @@ -24,6 +24,7 @@ import { CippApiDialog } from '../CippComponents/CippApiDialog' import { getCippError } from '../../utils/get-cipp-error' import { Box } from '@mui/system' import { useSettings } from '../../hooks/use-settings' +import { parseCippDate } from '../../utils/parse-cipp-date' import { isEqual } from 'lodash' // Import lodash for deep comparison import { useLicenseBackfill } from '../../hooks/use-license-backfill' @@ -80,12 +81,16 @@ const compareNullable = (aVal, bVal) => { // These never change between renders, so extracting them avoids creating new // object references on every render cycle. +// Stable ref so an undefined `data` prop doesn't create a fresh [] each render +// and loop the static-data sync effect. +const EMPTY_ARRAY = [] + const SORTING_FNS = { dateTimeNullsLast: (a, b, id) => { const aRaw = getRowValueByColumnId(a, id) const bRaw = getRowValueByColumnId(b, id) - const aDate = aRaw ? new Date(aRaw) : null - const bDate = bRaw ? new Date(bRaw) : null + const aDate = aRaw ? parseCippDate(aRaw) : null + const bDate = bRaw ? parseCippDate(bRaw) : null const aTime = aDate && !Number.isNaN(aDate.getTime()) ? aDate.getTime() : null const bTime = bDate && !Number.isNaN(bDate.getTime()) ? bDate.getTime() : null @@ -327,7 +332,7 @@ function renderColumnFilterModeMenuItemsFn({ internalFilterOptions, onSelectFilt export const CippDataTable = (props) => { const { queryKey, - data = [], + data = EMPTY_ARRAY, columns = [], api = {}, isFetching = false, @@ -729,13 +734,16 @@ export const CippDataTable = (props) => { sx={{ color: action.color }} key={`actions-list-row-${index}`} onClick={() => { - if (settings.currentTenant === 'AllTenants' && row.original?.Tenant) { - settings.handleUpdate({ - currentTenant: row.original.Tenant, - }) + const scopeToRowTenant = () => { + if (settings.currentTenant === 'AllTenants' && row.original?.Tenant) { + settings.handleUpdate({ + currentTenant: row.original.Tenant, + }) + } } if (action.noConfirm && action.customFunction) { + scopeToRowTenant() action.customFunction(row.original, action, {}) closeMenu() return @@ -743,6 +751,7 @@ export const CippDataTable = (props) => { // Handle custom component differently if (typeof action.customComponent === 'function') { + scopeToRowTenant() setCustomComponentData({ data: row.original, action: action }) setCustomComponentVisible(true) closeMenu() diff --git a/src/components/CippTable/util-columnsFromAPI.js b/src/components/CippTable/util-columnsFromAPI.js index 65fdbb411f19..ad170fc3ddff 100644 --- a/src/components/CippTable/util-columnsFromAPI.js +++ b/src/components/CippTable/util-columnsFromAPI.js @@ -2,8 +2,7 @@ import { getCippFilterVariant } from '../../utils/get-cipp-filter-variant' import { getCippFormatting } from '../../utils/get-cipp-formatting' import { getCippTranslation } from '../../utils/get-cipp-translation' import { getCippColumnSize } from '../../utils/get-cipp-column-size' - -const skipRecursion = ['location', 'ScheduledBackupValues', 'Tenant'] +import { SKIP_RECURSION_KEYS as skipRecursion } from '../../utils/skip-recursion-keys' // Number of rows to sample when measuring column content width. const MAX_SIZE_SAMPLE = 30 @@ -36,7 +35,12 @@ const TIME_AGO_NAMES = new Set([ 'requestDate', 'reviewedDate', 'GeneratedAt', ]) const MATCH_DATE_TIME = /([dD]ate[tT]ime|[Ee]xpiration|[Tt]imestamp|[sS]tart[Dd]ate)/ -const isDateTimeColumn = (key) => TIME_AGO_NAMES.has(key) || MATCH_DATE_TIME.test(key) +const ABSOLUTE_DATE_NAMES = new Set([ + 'WindowStart', 'WindowEnd', 'CreatedUtc', 'DownloadedUtc', 'ProcessedUtc', + 'NextAttemptUtc', 'LastErrorUtc', 'LastPolledUtc', +]) +const isDateTimeColumn = (key) => + TIME_AGO_NAMES.has(key) || ABSOLUTE_DATE_NAMES.has(key) || MATCH_DATE_TIME.test(key) // Measure the pixel width a column needs based on its header and sampled cell values. // rawValues are the original data values (before formatting) — if they contain arrays or diff --git a/src/components/CippTestDetail/CippTestDetailOffCanvas.jsx b/src/components/CippTestDetail/CippTestDetailOffCanvas.jsx index e16c50563554..45e153d2ec5c 100644 --- a/src/components/CippTestDetail/CippTestDetailOffCanvas.jsx +++ b/src/components/CippTestDetail/CippTestDetailOffCanvas.jsx @@ -88,6 +88,9 @@ export const markdownStyles = { fontWeight: "bold", }, "& table": { + display: "block", + overflowX: "auto", + maxWidth: "100%", width: "100%", borderCollapse: "collapse", marginTop: 2, diff --git a/src/components/CippWizard/CippAddTenantTypeSelection.jsx b/src/components/CippWizard/CippAddTenantTypeSelection.jsx index 520d1b978936..f42c0d6d632f 100644 --- a/src/components/CippWizard/CippAddTenantTypeSelection.jsx +++ b/src/components/CippWizard/CippAddTenantTypeSelection.jsx @@ -2,12 +2,28 @@ import { Avatar, Card, CardContent, Stack, SvgIcon, Typography } from '@mui/mate import { useState, useEffect } from 'react' import { CippWizardStepButtons } from './CippWizardStepButtons' import { BuildingOfficeIcon, CloudIcon, LinkIcon } from '@heroicons/react/24/outline' +import { ApiGetCall } from '../../api/ApiCall' export const CippAddTenantTypeSelection = (props) => { const { onNextStep, formControl, currentStep, onPreviousStep } = props const [selectedOption, setSelectedOption] = useState(null) + // Fetch host tenant organization to check partnerTenantType. + // No tenantFilter means the backend defaults to $env:TenantID (the CIPP host tenant). + const organization = ApiGetCall({ + url: '/api/ListGraphRequest', + queryKey: 'ListGraphRequest-organization-partnerTenantType', + data: { + Endpoint: 'organization', + $select: 'partnerTenantType,displayName', + }, + }) + + const partnerTenantType = organization.data?.Results?.[0]?.partnerTenantType + const isPartner = organization.isSuccess && Boolean(partnerTenantType) + const partnerCheckComplete = organization.isSuccess || organization.isError + // Register the tenantType field in react-hook-form formControl.register('tenantType', { required: true, @@ -26,6 +42,18 @@ export const CippAddTenantTypeSelection = (props) => { } }, [formControl]) + // Clear selection if confirmed non-partner and a partner-only option was selected + useEffect(() => { + if (organization.isSuccess && !isPartner) { + const currentValue = formControl.getValues('tenantType') + if (currentValue === 'GDAP' || currentValue === 'IndirectReseller') { + formControl.setValue('tenantType', '') + setSelectedOption(null) + formControl.trigger('tenantType') + } + } + }, [organization.isSuccess, isPartner, formControl]) + const handleOptionClick = (value) => { setSelectedOption(value) formControl.setValue('tenantType', value) @@ -61,6 +89,7 @@ export const CippAddTenantTypeSelection = (props) => { description: "Select this option to add a new tenant to your Microsoft Partner center environment. We'll walk you through the steps of setting up GDAP.", icon: , + partnerOnly: true, }, { value: 'Direct', @@ -68,6 +97,7 @@ export const CippAddTenantTypeSelection = (props) => { description: 'Select this option if you are not a Microsoft partner, or want to add a tenant outside of the scope of your partner center.', icon: , + partnerOnly: false, }, { value: 'IndirectReseller', @@ -75,6 +105,7 @@ export const CippAddTenantTypeSelection = (props) => { description: 'Generate a reseller relationship invite link to send to a customer. This does not add the tenant to CIPP, but may be used by other vendors to populate their customer list.', icon: , + partnerOnly: true, }, ] @@ -89,19 +120,22 @@ export const CippAddTenantTypeSelection = (props) => { {options.map((option) => { const isSelected = selectedOption === option.value + const isDisabled = option.partnerOnly && partnerCheckComplete && !isPartner return ( handleOptionClick(option.value)} + onClick={isDisabled ? undefined : () => handleOptionClick(option.value)} variant="outlined" sx={{ - cursor: 'pointer', - ...(isSelected && { - boxShadow: (theme) => `0px 0px 0px 2px ${theme.palette.primary.main}`, - }), + cursor: isDisabled ? 'not-allowed' : 'pointer', + opacity: isDisabled ? 0.5 : 1, + ...(isSelected && + !isDisabled && { + boxShadow: (theme) => `0px 0px 0px 2px ${theme.palette.primary.main}`, + }), '&:hover': { - ...(isSelected ? {} : { boxShadow: 8 }), + ...(isDisabled ? {} : isSelected ? {} : { boxShadow: 8 }), }, }} > 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 } = props @@ -24,6 +28,40 @@ export const CippWizardOffboarding = (props) => { const userSettingsDefaults = useSettings().userSettingsDefaults const disableForwarding = useWatch({ control: formControl.control, name: 'disableForwarding' }) const deleteUser = useWatch({ control: formControl.control, name: 'DeleteUser' }) + const convertToShared = useWatch({ control: formControl.control, name: 'ConvertToShared' }) + + // Pull cached mailbox sizes (storageUsedInBytes, keyed by UPN) only when relevant + const mailboxUsage = ApiGetCall({ + url: '/api/ListMailboxes', + data: { tenantFilter: currentTenant?.value, UseReportDB: true }, + queryKey: `OffboardingMailboxUsage-${currentTenant?.value}`, + waiting: !!convertToShared && !!currentTenant?.value && selectedUsers?.length > 0, + }) + + // Selected mailboxes whose cached size would exceed the shared-mailbox limit + const oversizedMailboxes = useMemo(() => { + if (!convertToShared || !mailboxUsage.isSuccess || !Array.isArray(mailboxUsage.data)) { + return [] + } + const selectedUpns = (selectedUsers || []).map((u) => + (u?.value ?? u)?.toString().toLowerCase(), + ) + return mailboxUsage.data + .filter((mb) => { + const upn = mb?.UPN?.toString().toLowerCase() + const bytes = Number(mb?.storageUsedInBytes) + return ( + upn && + selectedUpns.includes(upn) && + Number.isFinite(bytes) && + bytes >= SHARED_MAILBOX_WARN_BYTES + ) + }) + .map((mb) => ({ + upn: mb.UPN, + sizeGB: (Number(mb.storageUsedInBytes) / 1024 ** 3).toFixed(1), + })) + }, [convertToShared, mailboxUsage.isSuccess, mailboxUsage.data, selectedUsers]) useEffect(() => { if (selectedUsers.length >= 3) { @@ -383,6 +421,21 @@ export const CippWizardOffboarding = (props) => { formControl={formControl} />
    + {convertToShared && oversizedMailboxes.length > 0 && ( + + The following mailbox{oversizedMailboxes.length > 1 ? 'es' : ''} exceed or are near + the 50 GB shared mailbox limit. Converting to shared may fail, or the mailbox may + stop receiving mail once unlicensed, unless an Exchange Online Plan 2 license is + retained: + + {oversizedMailboxes.map((mb) => ( +
  • + {mb.upn} ({mb.sizeGB} GB) +
  • + ))} +
    +
    + )}
    diff --git a/src/components/CippWizard/CippWizardVacationActions.jsx b/src/components/CippWizard/CippWizardVacationActions.jsx index 685757bbf9ff..3f23bcc484a6 100644 --- a/src/components/CippWizard/CippWizardVacationActions.jsx +++ b/src/components/CippWizard/CippWizardVacationActions.jsx @@ -17,6 +17,7 @@ import { CippFormUserSelector } from '../CippComponents/CippFormUserSelector' import { useWatch } from 'react-hook-form' import { ApiGetCall } from '../../api/ApiCall' import { getCippValidator } from '../../utils/get-cipp-validator' +import countryList from '../../data/countryList.json' export const CippWizardVacationActions = (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/ExecutiveReportButton.js b/src/components/ExecutiveReportButton.js index 8481583c2465..d0ef3ee37a84 100644 --- a/src/components/ExecutiveReportButton.js +++ b/src/components/ExecutiveReportButton.js @@ -34,6 +34,7 @@ import { import { useSettings } from '../hooks/use-settings' import { useSecureScore } from '../hooks/use-securescore' import { ApiGetCall } from '../api/ApiCall' +import { ShadowAIReportPages } from './ShadowAIReportButton' // PRODUCTION-GRADE PDF SYSTEM WITH CONDITIONAL RENDERING const ExecutiveReportDocument = ({ @@ -47,6 +48,7 @@ const ExecutiveReportDocument = ({ standardsCompareData, driftComplianceData, standardTemplatesData, + shadowAIData, sectionConfig = { executiveSummary: true, securityStandards: true, @@ -56,6 +58,7 @@ const ExecutiveReportDocument = ({ deviceManagement: true, conditionalAccess: true, infographics: true, + shadowAI: false, }, }) => { const currentDate = new Date().toLocaleDateString('en-US', { @@ -2684,6 +2687,27 @@ const ExecutiveReportDocument = ({ )} + + {/* SHADOW AI SECTION - the Shadow AI report pages appended to this document (no + separate cover page; the exec report already has one) */} + {sectionConfig.shadowAI && shadowAIData && ( + + )} ) } @@ -2704,6 +2728,7 @@ export const ExecutiveReportButton = (props) => { deviceManagement: true, conditionalAccess: true, infographics: true, + shadowAI: false, }) // Fetch organization data - only when preview is open @@ -2786,6 +2811,16 @@ export const ExecutiveReportButton = (props) => { waiting: previewOpen, }) + // Shadow AI data for the optional Shadow AI section - only fetched when that section is + // enabled. Requires a single tenant; the CIPPDb cache must have been synced for data to show. + const shadowAIEnabled = sectionConfig.shadowAI && settings.currentTenant !== 'AllTenants' + const shadowAIData = ApiGetCall({ + url: '/api/ListShadowAI', + data: { tenantFilter: settings.currentTenant }, + queryKey: `ListShadowAI-${settings.currentTenant}`, + waiting: previewOpen && shadowAIEnabled, + }) + // Check if all data is loaded (either successful or failed) - only relevant when preview is open const isDataLoading = previewOpen && @@ -2797,7 +2832,8 @@ export const ExecutiveReportButton = (props) => { conditionalAccessData.isFetching || standardsCompareData.isFetching || driftComplianceData.isFetching || - standardTemplatesData.isFetching) + standardTemplatesData.isFetching || + (shadowAIEnabled && shadowAIData.isFetching)) const hasAllDataFinished = !previewOpen || @@ -2809,7 +2845,8 @@ export const ExecutiveReportButton = (props) => { (conditionalAccessData.isSuccess || conditionalAccessData.isError) && (standardsCompareData.isSuccess || standardsCompareData.isError) && (driftComplianceData.isSuccess || driftComplianceData.isError) && - (standardTemplatesData.isSuccess || standardTemplatesData.isError)) + (standardTemplatesData.isSuccess || standardTemplatesData.isError) && + (!shadowAIEnabled || shadowAIData.isSuccess || shadowAIData.isError)) // Button is always available now since we don't need to wait for data const shouldShowButton = true @@ -2870,6 +2907,7 @@ export const ExecutiveReportButton = (props) => { standardTemplatesData={ standardTemplatesData.isSuccess ? standardTemplatesData?.data : null } + shadowAIData={shadowAIEnabled && shadowAIData.isSuccess ? shadowAIData.data : null} sectionConfig={sectionConfig} /> ) @@ -2900,6 +2938,7 @@ export const ExecutiveReportButton = (props) => { conditionalAccessData?.isSuccess, standardsCompareData?.isSuccess, driftComplianceData?.isSuccess, + shadowAIData?.isSuccess, JSON.stringify(sectionConfig), // Stringify to prevent reference issues ]) @@ -2987,6 +3026,11 @@ export const ExecutiveReportButton = (props) => { label: 'Infographic Pages', description: 'Statistical pages with visual elements between sections', }, + { + key: 'shadowAI', + label: 'Shadow AI Report', + description: 'AI usage discovery and risk pages from the Shadow AI report', + }, ] return ( @@ -3261,6 +3305,9 @@ export const ExecutiveReportButton = (props) => { driftComplianceData={ driftComplianceData.isSuccess ? driftComplianceData?.data : null } + shadowAIData={ + shadowAIEnabled && shadowAIData.isSuccess ? shadowAIData.data : null + } sectionConfig={sectionConfig} /> ) diff --git a/src/components/PrivateRoute.js b/src/components/PrivateRoute.js index 15b438b2c608..8ac75ccec877 100644 --- a/src/components/PrivateRoute.js +++ b/src/components/PrivateRoute.js @@ -4,6 +4,13 @@ import LoadingPage from "../pages/loading.js"; import ApiOfflinePage from "../pages/api-offline.js"; import { useState, useEffect } from "react"; +// EasyAuth exposes the signed-in identity in two shapes depending on the host: +// - Static Web Apps: { clientPrincipal: { userDetails, userRoles, ... } } +// - App Service EasyAuth: [ { user_id, user_claims: [...], access_token, ... } ] +// an authenticated session must be detected from either populated shape. +const hasAuthenticatedSession = (data) => + Boolean(data?.clientPrincipal) || (Array.isArray(data) && data.length > 0); + export const PrivateRoute = ({ children, routeType }) => { const [unauthLatched, setUnauthLatched] = useState(false); @@ -14,27 +21,26 @@ export const PrivateRoute = ({ children, routeType }) => { staleTime: 120000, // 2 minutes }); - // Latch the unauthenticated state so refetches from child components - // don't flip us back to loading. Clear the latch when session succeeds (after login). + // Latch the unauthenticated state so refetches from child components don't flip us + // back to loading. Latch on a request error or a settled session with no identity; + // clear it as soon as an authenticated session (either shape) is seen. useEffect(() => { if ( !session.isLoading && !session.isFetching && - (session.isError || - null === session?.data?.clientPrincipal || - session?.data === undefined) + (session.isError || !hasAuthenticatedSession(session.data)) ) { setUnauthLatched(true); - } else if (session.isSuccess && session.data?.clientPrincipal) { + } else if (hasAuthenticatedSession(session.data)) { setUnauthLatched(false); } - }, [session.isLoading, session.isFetching, session.isError, session.isSuccess, session.data]); + }, [session.isLoading, session.isFetching, session.isError, session.data]); const apiRoles = ApiGetCall({ url: "/api/me", queryKey: "authmecipp", retry: 2, - waiting: session.isSuccess && session.data?.clientPrincipal !== null, + waiting: session.isSuccess && hasAuthenticatedSession(session.data), }); // If latched as unauthenticated, always show unauthenticated page @@ -43,10 +49,12 @@ export const PrivateRoute = ({ children, routeType }) => { } // Check if the session is still loading before determining authentication status + // return loading page here when roles are loading to avoid showing 401 if ( session.isLoading || apiRoles.isLoading || - (apiRoles.isFetching && (apiRoles.data === null || apiRoles.data === undefined)) + (hasAuthenticatedSession(session.data) && apiRoles.isPending) || + (apiRoles.isFetching && !apiRoles.data?.clientPrincipal) ) { return ; } diff --git a/src/components/ReleaseNotesDialog.js b/src/components/ReleaseNotesDialog.js index 5b99535d1c36..dbdf649ed410 100644 --- a/src/components/ReleaseNotesDialog.js +++ b/src/components/ReleaseNotesDialog.js @@ -32,7 +32,7 @@ import { CippAutoComplete } from './CippComponents/CippAutocomplete' const RELEASE_COOKIE_KEY = 'cipp_release_notice' const RELEASE_PERMANENT_HIDE_KEY = 'cipp_release_notice_permanently_hidden' -const RELEASE_OWNER = 'KelvinTegelaar' +const RELEASE_OWNER = 'CyberDrain' const RELEASE_REPO = 'CIPP' const secureFlag = () => { @@ -168,11 +168,7 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { const releaseListQuery = ApiGetCall({ url: '/api/ListGitHubReleaseNotes', - queryKey: 'list-github-release-options', - data: { - Owner: RELEASE_OWNER, - Repository: RELEASE_REPO, - }, + queryKey: `list-github-release-options`, waiting: shouldFetchReleaseList, staleTime: 300000, }) @@ -484,7 +480,13 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { > View release notes on GitHub - + + + + + 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 0d0588d2b624..9eac150e0215 100644 --- a/src/data/CIPPDBCacheTypes.json +++ b/src/data/CIPPDBCacheTypes.json @@ -279,6 +279,21 @@ "friendlyName": "SharePoint Site Usage", "description": "SharePoint site usage statistics" }, + { + "type": "SiteActivity", + "friendlyName": "Site Activity", + "description": "Per-site activity with siteType classification (SharePoint, SharePointAndTeams, OneDrive) and workload activity columns" + }, + { + "type": "SharePointSharingLinks", + "friendlyName": "SharePoint & OneDrive Sharing Links", + "description": "Sharing links and external grants on SharePoint and OneDrive files and folders" + }, + { + "type": "SharePointPermissions", + "friendlyName": "SharePoint Permissions", + "description": "Site and document library permission assignments, including libraries that no longer inherit and grants to tenant-wide claims such as Everyone except external users" + }, { "type": "ConditionalAccessPolicies", "friendlyName": "Conditional Access Policies", @@ -328,5 +343,15 @@ "type": "DetectedApps", "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", + "description": "Per-application install status rollup (failed/installed/pending device counts) from the AppInstallStatusAggregate report" } ] 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..de986113d5aa 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", @@ -661,7 +661,7 @@ "IsEnabled": true, "IsSystem": true, "Name": "SharePoint Embedded Administrator", - "ObjectId": "1a7d78b6-429f-476b-8eb-35fb715fffd4" + "ObjectId": "1a7d78b6-429f-476b-b8eb-35fb715fffd4" }, { "ExtensionData": {}, @@ -809,18 +809,34 @@ }, { "ExtensionData": {}, - "Description": "Assign the Customer Delegated Admin Relationship Administrator role to users who need to accept, view, or terminate GDAP relationships with partners.", + "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.", "IsEnabled": true, "IsSystem": true, - "Name": "Customer Delegated Admin Relationship Administrator", - "ObjectId": "fc8ad4e2-40e4-4724-8317-bcda7503ecbf" + "Name": "AI Administrator", + "ObjectId": "d2562ede-74db-457e-a7b6-544e236ebb61" }, { "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.", + "Description": "Manage all aspects of the Microsoft Dragon admin center.", "IsEnabled": true, "IsSystem": true, - "Name": "AI Administrator", - "ObjectId": "d2562ede-74db-457e-a7b6-544e236ebb61" + "Name": "Dragon Administrator", + "ObjectId": "e93e3737-fa85-474a-aee4-7d3fb86510f3" + }, + { + "ExtensionData": {}, + "Description": "Manage all aspects of organizational branding in a tenant.", + "IsEnabled": true, + "IsSystem": true, + "Name": "Organizational Branding Administrator", + "ObjectId": "92ed04bf-c94a-4b82-9729-b799a7a4c178" + }, + { + "ExtensionData": {}, + "Description": "Review, approve, or reject new organizational messages for delivery in the Microsoft 365 admin center before they are sent to users.", + "IsEnabled": true, + "IsSystem": true, + "Name": "Organizational Messages Approver", + "ObjectId": "e48398e2-f4bb-4074-8f31-4586725e205b" } ] 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/M365Licenses.json b/src/data/M365Licenses.json index 3f725239335b..cb6847be1d57 100644 --- a/src/data/M365Licenses.json +++ b/src/data/M365Licenses.json @@ -46846,5 +46846,989 @@ "Service_Plan_Name": "AAD_WRKLDID_P1", "Service_Plan_Id": "84c289f0-efcb-486f-8581-07f44fc9efad", "Service_Plans_Included_Friendly_Names": "Azure Active Directory workload identities P1" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "ENTRA_NETWORK_CONTROLS_FOR_ASSISTIVE_AGENTS", + "Service_Plan_Id": "27e196a4-8b80-4930-bd65-53fd28581878", + "Service_Plans_Included_Friendly_Names": "Microsoft Entra Network Controls for Assistive Agents" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "ENTRA_ID_GOV_FOR_ASSISTIVE_AGENTS", + "Service_Plan_Id": "a9e85e05-1687-4958-8a4c-bdacda2943db", + "Service_Plans_Included_Friendly_Names": "Microsoft Entra ID Governance for Assistive Agents" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "INSIDER_RISK_MANAGEMENT_FOR_AGENTS", + "Service_Plan_Id": "004ddfc0-c92f-4b0a-90c5-c60646299d71", + "Service_Plans_Included_Friendly_Names": "Microsoft Purview Insider Risk Management for Agents" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "INFORMATION_PROTECTION_FOR_AGENTS", + "Service_Plan_Id": "48478b49-91a1-4ded-94f0-066db80035ca", + "Service_Plans_Included_Friendly_Names": "Microsoft Purview Information Protection for Agents" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "EDISCOVERY_FOR_AGENTS", + "Service_Plan_Id": "92cedcb2-3fb2-40b4-9df4-f9a5603d9631", + "Service_Plans_Included_Friendly_Names": "Microsoft Purview eDiscovery for Agents" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "DEFENDER_FOR_AI", + "Service_Plan_Id": "a1c15058-5559-4c1a-ba05-8040847f91bb", + "Service_Plans_Included_Friendly_Names": "Microsoft Defender for AI" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "DATA_LOSS_PREVENTION_FOR_AGENTS", + "Service_Plan_Id": "46d3c309-0ba7-461f-9d0e-eaca165794c2", + "Service_Plans_Included_Friendly_Names": "Microsoft Purview Data Loss Prevention for Agents" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "DATA_LIFECYCLE_MANAGEMENT_FOR_AGENTS", + "Service_Plan_Id": "30d56d35-9be2-41c1-b4b8-7a8d6f073152", + "Service_Plans_Included_Friendly_Names": "Microsoft Purview Data Lifecycle Management for Agents" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "COMPLIANCE_MANAGER_FOR_AGENTS", + "Service_Plan_Id": "d1f65d05-a302-4861-bd35-da8933ba7655", + "Service_Plans_Included_Friendly_Names": "Microsoft Purview Compliance Manager for Agents" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "COMMUNICATION_COMPLIANCE_FOR_AGENTS", + "Service_Plan_Id": "135fe762-031a-4e79-bd3c-ac376addddec", + "Service_Plans_Included_Friendly_Names": "Microsoft Purview Communication Compliance for Agents" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "AUDIT_FOR_AGENTS", + "Service_Plan_Id": "6d9b0ae5-e6a0-4f04-991a-e5700fd84930", + "Service_Plans_Included_Friendly_Names": "Microsoft Purview Audit for Agents" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "AGENT_365", + "Service_Plan_Id": "d0ce5ebb-9db0-491f-b780-8973a1d815fe", + "Service_Plans_Included_Friendly_Names": "Agent 365" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "Defender_for_Iot_Enterprise", + "Service_Plan_Id": "99cd49a9-0e54-4e07-aea1-d8d9f5f704f5", + "Service_Plans_Included_Friendly_Names": "Defender for IoT - Enterprise IoT Security" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "DYN365_CDS_O365_P3", + "Service_Plan_Id": "28b0fa46-c39a-4188-89e2-58e979a6b014", + "Service_Plans_Included_Friendly_Names": "Common Data Service" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "RMS_S_PREMIUM2", + "Service_Plan_Id": "5689bec4-755d-4753-8b61-40975025187c", + "Service_Plans_Included_Friendly_Names": "AZURE INFORMATION PROTECTION PREMIUM P2" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "RMS_S_PREMIUM", + "Service_Plan_Id": "6c57d4b6-3b23-47a5-9bc9-69f17b4947b3", + "Service_Plans_Included_Friendly_Names": "Microsoft Entra RIGHTS" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "Verifiable_Credentials_Service_Request", + "Service_Plan_Id": "aae826b7-14cd-4691-8178-2b312f7072ea", + "Service_Plans_Included_Friendly_Names": "Verifiable Credentials Service Request" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "POWER_VIRTUAL_AGENTS_O365_P3", + "Service_Plan_Id": "ded3d325-1bdc-453e-8432-5bac26d7a014", + "Service_Plans_Included_Friendly_Names": "Power Virtual Agents for Office 365" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "M365_COPILOT_CONNECTORS", + "Service_Plan_Id": "89f1c4c8-0878-40f7-804d-869c9128ab5d", + "Service_Plans_Included_Friendly_Names": "Power Platform Connectors in Microsoft 365 Copilot" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "FLOW_O365_P3", + "Service_Plan_Id": "07699545-9485-468e-95b6-2fca3738be01", + "Service_Plans_Included_Friendly_Names": "Power Automate for Office 365" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "WORKPLACE_ANALYTICS_INSIGHTS_BACKEND", + "Service_Plan_Id": "ff7b261f-d98b-415b-827c-42a3fdf015af", + "Service_Plans_Included_Friendly_Names": "Microsoft Viva Insights Backend" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "WORKPLACE_ANALYTICS_INSIGHTS_USER", + "Service_Plan_Id": "b622badb-1b45-48d5-920f-4b27a2c0996c", + "Service_Plans_Included_Friendly_Names": "Microsoft Viva Insights" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "INTUNE_A", + "Service_Plan_Id": "c1ec4a95-1f05-45b3-a911-aa3fa01094f5", + "Service_Plans_Included_Friendly_Names": "Microsoft Intune" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "Entra_Premium_Private_Access", + "Service_Plan_Id": "f057aab1-b184-49b2-85c0-881b02a405c5", + "Service_Plans_Included_Friendly_Names": "Microsoft Entra Private Access" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "Entra_Premium_Internet_Access", + "Service_Plan_Id": "8d23cb83-ab07-418f-8517-d7aca77307dc", + "Service_Plans_Included_Friendly_Names": "Microsoft Entra Internet Access" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "AAD_PREMIUM_P2", + "Service_Plan_Id": "eec0eb4f-6444-4f95-aba0-50c24d67f998", + "Service_Plans_Included_Friendly_Names": "Microsoft Entra ID P2" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "AAD_PREMIUM", + "Service_Plan_Id": "41781fb2-bc02-4b7c-bd55-b576c07bb09d", + "Service_Plans_Included_Friendly_Names": "Microsoft Entra ID P1" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "ATA", + "Service_Plan_Id": "14ab5db5-e6c4-4b20-b4bc-13e36fd2227f", + "Service_Plans_Included_Friendly_Names": "MICROSOFT DEFENDER FOR IDENTITY" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "ADALLOM_S_STANDALONE", + "Service_Plan_Id": "2e2ddb96-6af9-4b1d-a3f0-d6ecfd22edb2", + "Service_Plans_Included_Friendly_Names": "MICROSOFT CLOUD APP SECURITY" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "M365_COPILOT_BUSINESS_CHAT", + "Service_Plan_Id": "3f30311c-6b1e-48a4-ab79-725b469da960", + "Service_Plans_Included_Friendly_Names": "Microsoft Copilot with Graph-grounded chat" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "MFA_PREMIUM", + "Service_Plan_Id": "8a256a2b-b617-496d-b51b-e76466e88db0", + "Service_Plans_Included_Friendly_Names": "Microsoft Azure Multi-Factor Authentication" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "M365_COPILOT_APPS", + "Service_Plan_Id": "a62f8878-de10-42f3-b68f-6149a25ceb97", + "Service_Plans_Included_Friendly_Names": "Microsoft 365 Copilot in Productivity Apps" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "M365_COPILOT_TEAMS", + "Service_Plan_Id": "b95945de-b3bd-46db-8437-f2beb6ea2347", + "Service_Plans_Included_Friendly_Names": "Microsoft 365 Copilot in Microsoft Teams" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "M365_COPILOT_SHAREPOINT", + "Service_Plan_Id": "0aedf20c-091d-420b-aadf-30c042609612", + "Service_Plans_Included_Friendly_Names": "Microsoft 365 Copilot for SharePoint" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "M365_COPILOT_INTELLIGENT_SEARCH", + "Service_Plan_Id": "931e4a88-a67f-48b5-814f-16a5f1e6028d", + "Service_Plans_Included_Friendly_Names": "Intelligent Search" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "GRAPH_CONNECTORS_COPILOT", + "Service_Plan_Id": "82d30987-df9b-4486-b146-198b21d164c7", + "Service_Plans_Included_Friendly_Names": "Graph Connectors in Microsoft 365 Copilot" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "Entra_Identity_Governance", + "Service_Plan_Id": "e866a266-3cff-43a3-acca-0c90a7e00c8b", + "Service_Plans_Included_Friendly_Names": "Entra Identity Governance" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "COPILOT_STUDIO_IN_COPILOT_FOR_M365", + "Service_Plan_Id": "fe6c28b3-d468-44ea-bbd0-a10a5167435c", + "Service_Plans_Included_Friendly_Names": "Copilot Studio in Copilot for M365" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "WINDOWSUPDATEFORBUSINESS_DEPLOYMENTSERVICE", + "Service_Plan_Id": "7bf960f6-2cd9-443a-8046-5dbff9558365", + "Service_Plans_Included_Friendly_Names": "Windows Update for Business Deployment Service" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "Windows_Autopatch", + "Service_Plan_Id": "9a6eeb79-0b4b-4bf0-9808-39d99a2cd5a3", + "Service_Plans_Included_Friendly_Names": "Windows Autopatch" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "WIN10_PRO_ENT_SUB", + "Service_Plan_Id": "21b439ba-a0ca-424f-a6cc-52f954a5b111", + "Service_Plans_Included_Friendly_Names": "Windows 10/11 Enterprise (Original)" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "UNIVERSAL_PRINT_01", + "Service_Plan_Id": "795f6fe0-cc4d-4773-b050-5dde4dc704c9", + "Service_Plans_Included_Friendly_Names": "Universal Print" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "MICROSOFTENDPOINTDLP", + "Service_Plan_Id": "64bfac92-2b17-4482-b5e5-a0304429de3e", + "Service_Plans_Included_Friendly_Names": "Microsoft Endpoint DLP" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "WINDEFATP", + "Service_Plan_Id": "871d91ec-ec1a-452b-a83f-bd76c7d770ef", + "Service_Plans_Included_Friendly_Names": "Microsoft Defender for Endpoint" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "YAMMER_ENTERPRISE", + "Service_Plan_Id": "7547a3fe-08ee-4ccb-b430-5077c5041653", + "Service_Plans_Included_Friendly_Names": "YAMMER_ENTERPRISE" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "WHITEBOARD_PLAN3", + "Service_Plan_Id": "4a51bca5-1eff-43f5-878c-177680f191af", + "Service_Plans_Included_Friendly_Names": "Whiteboard (Plan 3)" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "VIVA_LEARNING_SEEDED", + "Service_Plan_Id": "b76fb638-6ba6-402a-b9f9-83d28acb3d86", + "Service_Plans_Included_Friendly_Names": "Viva Learning Seeded" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "VIVAENGAGE_CORE", + "Service_Plan_Id": "a82fbf69-b4d7-49f4-83a6-915b2cf354f4", + "Service_Plans_Included_Friendly_Names": "Viva Engage Core" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "BPOS_S_TODO_3", + "Service_Plan_Id": "3fb82609-8c27-4f7b-bd51-30634711ee67", + "Service_Plans_Included_Friendly_Names": "To-Do (Plan 3)" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "SWAY", + "Service_Plan_Id": "a23b959c-7ce8-4e57-9140-b90eb88a9e97", + "Service_Plans_Included_Friendly_Names": "Sway" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "MCOSTANDARD", + "Service_Plan_Id": "0feaeb32-d00e-4d66-bd5a-43b5b83db82c", + "Service_Plans_Included_Friendly_Names": "Skype for Business Online (Plan 2)" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "SHAREPOINTENTERPRISE", + "Service_Plan_Id": "5dbe027f-2339-4123-9542-606e4d348a72", + "Service_Plans_Included_Friendly_Names": "SharePoint (Plan 2)" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "PURVIEW_DISCOVERY", + "Service_Plan_Id": "c948ea65-2053-4a5a-8a62-9eaaaf11b522", + "Service_Plans_Included_Friendly_Names": "Purview Discovery" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "PROJECT_O365_P3", + "Service_Plan_Id": "b21a6b06-1988-436e-a07b-51ec6d9f52ad", + "Service_Plans_Included_Friendly_Names": "Project for Office (Plan E5)" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "PREMIUM_ENCRYPTION", + "Service_Plan_Id": "617b097b-4b93-4ede-83de-5f075bb5fb2f", + "Service_Plans_Included_Friendly_Names": "Premium Encryption in Office 365" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "BI_AZURE_P2", + "Service_Plan_Id": "70d33638-9c74-4d01-bfd3-562de28bd4ba", + "Service_Plans_Included_Friendly_Names": "Power BI Pro" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "POWERAPPS_O365_P3", + "Service_Plan_Id": "9c0dab89-a30c-4117-86e7-97bda240acd2", + "Service_Plans_Included_Friendly_Names": "Power Apps for Office 365 (Plan 3)" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "PEOPLE_SKILLS_FOUNDATION", + "Service_Plan_Id": "13b6da2c-0d84-450e-9f69-a33e221387ca", + "Service_Plans_Included_Friendly_Names": "People Skills - Foundation" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "SHAREPOINTWAC", + "Service_Plan_Id": "e95bec33-7c88-4a70-8e19-b10bd9d0c014", + "Service_Plans_Included_Friendly_Names": "Office for the Web" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "SAFEDOCS", + "Service_Plan_Id": "bf6f5520-59e3-4f82-974b-7dbbc4fd27c7", + "Service_Plans_Included_Friendly_Names": "Office 365 SafeDocs" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "PAM_ENTERPRISE", + "Service_Plan_Id": "b1188c4c-1b36-4018-b48b-ee07604f6feb", + "Service_Plans_Included_Friendly_Names": "Office 365 Privileged Access Management" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "ADALLOM_S_O365", + "Service_Plan_Id": "8c098270-9dd4-4350-9b30-ba4703f3b36b", + "Service_Plans_Included_Friendly_Names": "Office 365 Cloud App Security" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "EQUIVIO_ANALYTICS", + "Service_Plan_Id": "4de31727-a228-4ec3-a5bf-8e45b5ca48cc", + "Service_Plans_Included_Friendly_Names": "Office 365 Advanced eDiscovery" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "Nucleus", + "Service_Plan_Id": "db4d623d-b514-490b-b7ef-8885eee514de", + "Service_Plans_Included_Friendly_Names": "Nucleus" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "INTUNE_O365", + "Service_Plan_Id": "882e1d05-acd1-4ccb-8708-6ee03664b117", + "Service_Plans_Included_Friendly_Names": "Mobile Device Management for Office 365" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "TEAMS1", + "Service_Plan_Id": "57ff2da0-773e-42df-b2af-ffb7a2317929", + "Service_Plans_Included_Friendly_Names": "Microsoft Teams" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "STREAM_O365_E5", + "Service_Plan_Id": "6c6042f5-6f01-4d67-b8c1-eb99d36eed3e", + "Service_Plans_Included_Friendly_Names": "Microsoft Stream for Office 365 E5" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "Deskless", + "Service_Plan_Id": "8c7d2df8-86f0-4902-b2ed-a0458298f3b3", + "Service_Plans_Included_Friendly_Names": "Microsoft StaffHub" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "MICROSOFT_SEARCH", + "Service_Plan_Id": "94065c59-bc8e-4e8b-89e5-5138d471eaff", + "Service_Plans_Included_Friendly_Names": "Microsoft Search" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "RECORDS_MANAGEMENT", + "Service_Plan_Id": "65cc641f-cccd-4643-97e0-a17e3045e541", + "Service_Plans_Included_Friendly_Names": "Microsoft Records Management" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "PROJECTWORKMANAGEMENT", + "Service_Plan_Id": "b737dad2-2f6c-4c65-90e3-ca563267e8b9", + "Service_Plans_Included_Friendly_Names": "Microsoft Planner" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "EXCHANGE_ANALYTICS", + "Service_Plan_Id": "34c0d7a0-a70f-4668-9238-47f9fc208882", + "Service_Plans_Included_Friendly_Names": "Microsoft MyAnalytics (Full)" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "ML_CLASSIFICATION", + "Service_Plan_Id": "d2d51368-76c9-4317-ada2-a12c004c432f", + "Service_Plans_Included_Friendly_Names": "Microsoft ML-Based Classification" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "MICROSOFT_LOOP", + "Service_Plan_Id": "c4b8c31a-fb44-4c65-9837-a21f55fcabda", + "Service_Plans_Included_Friendly_Names": "Microsoft Loop" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "INSIDER_RISK_MANAGEMENT", + "Service_Plan_Id": "9d0c4ee5-e4a1-4625-ab39-d82b619b1a34", + "Service_Plans_Included_Friendly_Names": "Microsoft Insider Risk Management - Exchange" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "INSIDER_RISK", + "Service_Plan_Id": "d587c7a3-bda9-4f99-8776-9bcf59c84f75", + "Service_Plans_Included_Friendly_Names": "Microsoft Insider Risk Management" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "INFO_GOVERNANCE", + "Service_Plan_Id": "e26c2fcc-ab91-4a61-b35c-03cdc8dddf66", + "Service_Plans_Included_Friendly_Names": "Microsoft Information Governance" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "FORMS_PLAN_E5", + "Service_Plan_Id": "e212cbc7-0961-4c40-9825-01117710dcb1", + "Service_Plans_Included_Friendly_Names": "Microsoft Forms (Plan E5)" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "EXCEL_PREMIUM", + "Service_Plan_Id": "531ee2f8-b1cb-453b-9c21-d2180d014ca5", + "Service_Plans_Included_Friendly_Names": "Microsoft Excel Advanced Analytics" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "THREAT_INTELLIGENCE", + "Service_Plan_Id": "8e0c0a52-6a6c-4d40-8370-dd62790dcd70", + "Service_Plans_Included_Friendly_Names": "Microsoft Defender for Office 365 (Plan 2)" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "ATP_ENTERPRISE", + "Service_Plan_Id": "f20fedf3-f3c3-43c3-8267-2bfdd51c0939", + "Service_Plans_Included_Friendly_Names": "Microsoft Defender for Office 365 (Plan 1)" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "CUSTOMER_KEY", + "Service_Plan_Id": "6db1f1db-2b46-403f-be40-e39395f08dbb", + "Service_Plans_Included_Friendly_Names": "Microsoft Customer Key" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "COMMUNICATIONS_DLP", + "Service_Plan_Id": "6dc145d6-95dd-4191-b9c3-185575ee6f6b", + "Service_Plans_Included_Friendly_Names": "Microsoft Communications DLP" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "CLIPCHAMP", + "Service_Plan_Id": "a1ace008-72f3-4ea0-8dac-33b3a23a2472", + "Service_Plans_Included_Friendly_Names": "Microsoft Clipchamp" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "MICROSOFTBOOKINGS", + "Service_Plan_Id": "199a5c09-e0ca-4e37-8f7c-b05d533e1ea2", + "Service_Plans_Included_Friendly_Names": "Microsoft Bookings" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "MCOEV", + "Service_Plan_Id": "4828c8ec-dc2e-4779-b502-87ac9ce28ab7", + "Service_Plans_Included_Friendly_Names": "Microsoft 365 Phone System" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "M365_LIGHTHOUSE_CUSTOMER_PLAN1", + "Service_Plan_Id": "6f23d6a9-adbf-481c-8538-b4c095654487", + "Service_Plans_Included_Friendly_Names": "Microsoft 365 Lighthouse (Plan 1)" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "MTP", + "Service_Plan_Id": "bf28f719-7844-4079-9c78-c1307898e192", + "Service_Plans_Included_Friendly_Names": "Microsoft 365 Defender" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "MICROSOFT_COMMUNICATION_COMPLIANCE", + "Service_Plan_Id": "a413a9ff-720c-4822-98ef-2f37c2a21f4c", + "Service_Plans_Included_Friendly_Names": "Microsoft 365 Communication Compliance" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "M365_AUDIT_PLATFORM", + "Service_Plan_Id": "f6de4823-28fa-440b-b886-4783fa86ddba", + "Service_Plans_Included_Friendly_Names": "Microsoft 365 Audit Platform" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "MCOMEETADV", + "Service_Plan_Id": "3e26ee1f-8a5f-4d52-aee2-b81ce45c8f40", + "Service_Plans_Included_Friendly_Names": "Microsoft 365 Audio Conferencing" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "OFFICESUBSCRIPTION", + "Service_Plan_Id": "43de0ff5-c92c-492b-9116-175376d08c38", + "Service_Plans_Included_Friendly_Names": "Microsoft 365 Apps for enterprise" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "M365_ADVANCED_AUDITING", + "Service_Plan_Id": "2f442157-a11c-46b9-ae5b-6e39ff4e5849", + "Service_Plans_Included_Friendly_Names": "Microsoft 365 Advanced Auditing" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "INSIGHTS_BY_MYANALYTICS", + "Service_Plan_Id": "b088306e-925b-44ab-baa0-63291c629a91", + "Service_Plans_Included_Friendly_Names": "Insights by MyAnalytics Backend" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "MYANALYTICS_P2", + "Service_Plan_Id": "33c4f319-9bdd-48d6-9c4d-410b750a4a5a", + "Service_Plans_Included_Friendly_Names": "Insights by MyAnalytics" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "MIP_S_CLP1", + "Service_Plan_Id": "5136a095-5cf0-4aff-bec3-e84448b38ea5", + "Service_Plans_Included_Friendly_Names": "Information Protection for Office 365 - Standard" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "MIP_S_CLP2", + "Service_Plan_Id": "efb0351d-3b08-4503-993d-383af8de41e3", + "Service_Plans_Included_Friendly_Names": "Information Protection for Office 365 - Premium" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "ContentExplorer_Standard", + "Service_Plan_Id": "2b815d45-56e4-4e3a-b65c-66cb9175b560", + "Service_Plans_Included_Friendly_Names": "Information Protection and Governance Analytics – Standard" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "Content_Explorer", + "Service_Plan_Id": "d9fa6af4-e046-4c89-9226-729a0786685d", + "Service_Plans_Included_Friendly_Names": "Information Protection and Governance Analytics - Premium" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "INFORMATION_BARRIERS", + "Service_Plan_Id": "c4801e8a-cb58-4c35-aca6-f2dcc106f287", + "Service_Plans_Included_Friendly_Names": "Information Barriers" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "MESH_IMMERSIVE_FOR_TEAMS", + "Service_Plan_Id": "f0ff6ac6-297d-49cd-be34-6dfef97f0c28", + "Service_Plans_Included_Friendly_Names": "Immersive spaces for Teams" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "GRAPH_CONNECTORS_SEARCH_INDEX", + "Service_Plan_Id": "a6520331-d7d4-4276-95f5-15c0933bc757", + "Service_Plans_Included_Friendly_Names": "Graph Connectors Search with Index" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "EXCHANGE_S_ENTERPRISE", + "Service_Plan_Id": "efb87545-963c-4e0d-99df-69c6916d9eb0", + "Service_Plans_Included_Friendly_Names": "EXCHANGE ONLINE (PLAN 2)" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "COMMON_DEFENDER_PLATFORM_FOR_OFFICE", + "Service_Plan_Id": "a312bdeb-1e21-40d0-84b1-0e73f128144f", + "Service_Plans_Included_Friendly_Names": "Defender Platform for Office 365" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "MIP_S_Exchange", + "Service_Plan_Id": "cd31b152-6326-4d1b-ae1b-997b625182e6", + "Service_Plans_Included_Friendly_Names": "Data Classification in Microsoft 365" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "CustomerLockboxA_Enterprise", + "Service_Plan_Id": "3ec18638-bd4c-4d3b-8905-479ed636b83e", + "Service_Plans_Included_Friendly_Names": "Customer Lockbox (A)" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "LOCKBOX_ENTERPRISE", + "Service_Plan_Id": "9f431833-0334-42de-a7dc-70aa40db46db", + "Service_Plans_Included_Friendly_Names": "Customer Lockbox" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "CDS_O365_P3", + "Service_Plan_Id": "afa73018-811e-46e9-988f-f75d2b1b8430", + "Service_Plans_Included_Friendly_Names": "Common Data Service for Teams" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "RMS_S_ENTERPRISE", + "Service_Plan_Id": "bea4c11e-220a-4e6d-8eb8-8ea15d019f90", + "Service_Plans_Included_Friendly_Names": "Azure Rights Management" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "MESH_AVATARS_ADDITIONAL_FOR_TEAMS", + "Service_Plan_Id": "3efbd4ed-8958-4824-8389-1321f8730af8", + "Service_Plans_Included_Friendly_Names": "Avatars for Teams (additional)" + }, + { + "Product_Display_Name": "Microsoft 365 E7", + "String_Id": "MICROSOFT_365_E7", + "GUID": "9a18296a-025f-4e37-9ffa-30bf8d1ce775", + "Service_Plan_Name": "MESH_AVATARS_FOR_TEAMS", + "Service_Plan_Id": "dcf9d2f4-772e-4434-b757-77a453cfbc02", + "Service_Plans_Included_Friendly_Names": "Avatars for Teams" + }, + { + "Product_Display_Name": "Agent 365", + "String_Id": "AGENT_365", + "GUID": "796a6fb4-740b-4d36-bf56-9c12ca7fa069", + "Service_Plan_Name": "AGENT_365", + "Service_Plan_Id": "d0ce5ebb-9db0-491f-b780-8973a1d815fe", + "Service_Plans_Included_Friendly_Names": "Agent 365" + }, + { + "Product_Display_Name": "Agent 365", + "String_Id": "AGENT_365", + "GUID": "796a6fb4-740b-4d36-bf56-9c12ca7fa069", + "Service_Plan_Name": "AUDIT_FOR_AGENTS", + "Service_Plan_Id": "6d9b0ae5-e6a0-4f04-991a-e5700fd84930", + "Service_Plans_Included_Friendly_Names": "Microsoft Purview Audit for Agents" + }, + { + "Product_Display_Name": "Agent 365", + "String_Id": "AGENT_365", + "GUID": "796a6fb4-740b-4d36-bf56-9c12ca7fa069", + "Service_Plan_Name": "COMMUNICATION_COMPLIANCE_FOR_AGENTS", + "Service_Plan_Id": "135fe762-031a-4e79-bd3c-ac376addddec", + "Service_Plans_Included_Friendly_Names": "Microsoft Purview Communication Compliance for Agents" + }, + { + "Product_Display_Name": "Agent 365", + "String_Id": "AGENT_365", + "GUID": "796a6fb4-740b-4d36-bf56-9c12ca7fa069", + "Service_Plan_Name": "COMPLIANCE_MANAGER_FOR_AGENTS", + "Service_Plan_Id": "d1f65d05-a302-4861-bd35-da8933ba7655", + "Service_Plans_Included_Friendly_Names": "Microsoft Purview Compliance Manager for Agents" + }, + { + "Product_Display_Name": "Agent 365", + "String_Id": "AGENT_365", + "GUID": "796a6fb4-740b-4d36-bf56-9c12ca7fa069", + "Service_Plan_Name": "DATA_LIFECYCLE_MANAGEMENT_FOR_AGENTS", + "Service_Plan_Id": "30d56d35-9be2-41c1-b4b8-7a8d6f073152", + "Service_Plans_Included_Friendly_Names": "Microsoft Purview Data Lifecycle Management for Agents" + }, + { + "Product_Display_Name": "Agent 365", + "String_Id": "AGENT_365", + "GUID": "796a6fb4-740b-4d36-bf56-9c12ca7fa069", + "Service_Plan_Name": "DATA_LOSS_PREVENTION_FOR_AGENTS", + "Service_Plan_Id": "46d3c309-0ba7-461f-9d0e-eaca165794c2", + "Service_Plans_Included_Friendly_Names": "Microsoft Purview Data Loss Prevention for Agents" + }, + { + "Product_Display_Name": "Agent 365", + "String_Id": "AGENT_365", + "GUID": "796a6fb4-740b-4d36-bf56-9c12ca7fa069", + "Service_Plan_Name": "DEFENDER_FOR_AI", + "Service_Plan_Id": "a1c15058-5559-4c1a-ba05-8040847f91bb", + "Service_Plans_Included_Friendly_Names": "Microsoft Defender for AI" + }, + { + "Product_Display_Name": "Agent 365", + "String_Id": "AGENT_365", + "GUID": "796a6fb4-740b-4d36-bf56-9c12ca7fa069", + "Service_Plan_Name": "EDISCOVERY_FOR_AGENTS", + "Service_Plan_Id": "92cedcb2-3fb2-40b4-9df4-f9a5603d9631", + "Service_Plans_Included_Friendly_Names": "Microsoft Purview eDiscovery for Agents" + }, + { + "Product_Display_Name": "Agent 365", + "String_Id": "AGENT_365", + "GUID": "796a6fb4-740b-4d36-bf56-9c12ca7fa069", + "Service_Plan_Name": "ENTRA_ID_GOV_FOR_ASSISTIVE_AGENTS", + "Service_Plan_Id": "a9e85e05-1687-4958-8a4c-bdacda2943db", + "Service_Plans_Included_Friendly_Names": "Microsoft Entra ID Governance for Assistive Agents" + }, + { + "Product_Display_Name": "Agent 365", + "String_Id": "AGENT_365", + "GUID": "796a6fb4-740b-4d36-bf56-9c12ca7fa069", + "Service_Plan_Name": "ENTRA_NETWORK_CONTROLS_FOR_ASSISTIVE_AGENTS", + "Service_Plan_Id": "27e196a4-8b80-4930-bd65-53fd28581878", + "Service_Plans_Included_Friendly_Names": "Microsoft Entra Network Controls for Assistive Agents" + }, + { + "Product_Display_Name": "Agent 365", + "String_Id": "AGENT_365", + "GUID": "796a6fb4-740b-4d36-bf56-9c12ca7fa069", + "Service_Plan_Name": "INFORMATION_PROTECTION_FOR_AGENTS", + "Service_Plan_Id": "48478b49-91a1-4ded-94f0-066db80035ca", + "Service_Plans_Included_Friendly_Names": "Microsoft Purview Information Protection for Agents" + }, + { + "Product_Display_Name": "Agent 365", + "String_Id": "AGENT_365", + "GUID": "796a6fb4-740b-4d36-bf56-9c12ca7fa069", + "Service_Plan_Name": "INSIDER_RISK_MANAGEMENT_FOR_AGENTS", + "Service_Plan_Id": "004ddfc0-c92f-4b0a-90c5-c60646299d71", + "Service_Plans_Included_Friendly_Names": "Microsoft Purview Insider Risk Management for Agents" } ] diff --git a/src/data/alerts.json b/src/data/alerts.json index e0f7ffc9b9a8..4a97f801feeb 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" } ] }, @@ -109,6 +114,44 @@ } ] }, + { + "name": "InactiveSharePointSites", + "label": "Alert on SharePoint sites that have not been active for X days", + "recommendedRunInterval": "7d", + "requiresInput": true, + "multipleInput": true, + "inputs": [ + { + "inputType": "number", + "inputLabel": "Days since last activity (default: 90)", + "inputName": "DaysSinceActivity" + }, + { + "inputType": "switch", + "inputLabel": "Include never-active sites?", + "inputName": "IncludeNeverActive" + } + ] + }, + { + "name": "InactiveTeamsSites", + "label": "Alert on Teams linked sites that have not been active for X days", + "recommendedRunInterval": "7d", + "requiresInput": true, + "multipleInput": true, + "inputs": [ + { + "inputType": "number", + "inputLabel": "Days since last activity (default: 90)", + "inputName": "DaysSinceActivity" + }, + { + "inputType": "switch", + "inputLabel": "Include never-active TeamsSite sites?", + "inputName": "IncludeNeverActive" + } + ] + }, { "name": "EntraConnectSyncStatus", "label": "Alert if Entra Connect sync is enabled and has not run in the last X hours", @@ -133,6 +176,17 @@ "inputType": "textField", "inputLabel": "Excluded mailbox UPNs (comma-separated). Supports custom variables like %excludefrommailboxalert% defined under CIPP > Settings > Custom Variables at tenant or AllTenants scope.", "inputName": "QuotaUsedExcludedMailboxes" + }, + { + "inputType": "autoComplete", + "inputLabel": "Mailbox types to alert on (default: all)", + "inputName": "QuotaUsedMailboxTypes", + "creatable": false, + "multiple": true, + "options": [ + { "label": "User mailboxes", "value": "User" }, + { "label": "Shared mailboxes", "value": "Shared" } + ] } ], "recommendedRunInterval": "1d" @@ -391,7 +445,7 @@ { "name": "IntunePolicyConflicts", "label": "Alert on Intune policy or app conflicts/errors", - "recommendedRunInterval": "4h", + "recommendedRunInterval": "1d", "requiresInput": true, "multipleInput": true, "inputs": [ @@ -441,9 +495,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?", @@ -627,5 +681,15 @@ } ], "description": "Monitors for emails reported by users through Outlook's built-in Report Phishing feature. Alerts when new user-reported email threat submissions are found in Microsoft Defender. Requires ThreatSubmission.ReadWrite.All permission." + }, + { + "name": "NewShadowAITool", + "label": "Alert on new Shadow AI tools detected", + "recommendedRunInterval": "1d", + "requiresInput": true, + "inputType": "switch", + "inputLabel": "Ignore sanctioned tools?", + "inputName": "IgnoreSanctionedTools", + "description": "Monitors the Shadow AI detection data (Intune detected apps and Entra consented applications) and alerts when an AI tool is seen in the tenant for the first time. The first run establishes a baseline without alerting. Uses the same cached data as the Shadow AI dashboard, so keep the Detected Apps and Service Principals caches syncing. Optionally ignores tools that have been marked as company sanctioned." } ] 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/sharePointTemplateSchemas.json b/src/data/sharePointTemplateSchemas.json new file mode 100644 index 000000000000..10480b114777 --- /dev/null +++ b/src/data/sharePointTemplateSchemas.json @@ -0,0 +1,148 @@ +[ + { + "templateEngineVersion": 1, + "title": "CIPP SharePoint Provisioning Template", + "description": "Schema for CIPP SharePoint provisioning templates stored in the templates table (PartitionKey SharePointTemplate). Add a new array entry for each engine version; do not mutate prior entries in place.", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["templateName", "templateEngineVersion", "siteTemplates"], + "properties": { + "templateEngineVersion": { + "type": "integer", + "const": 1, + "description": "Provisioning engine version that authored or last saved this template." + }, + "templateName": { + "type": "string", + "minLength": 1, + "description": "Display name of the provisioning template." + }, + "siteType": { + "type": "string", + "enum": ["sharePoint", "teams"], + "description": "Template-level site type used when overrideSiteType is true." + }, + "overrideSiteType": { + "type": "boolean", + "description": "When true, every site card deploys using the template-level siteType." + }, + "createMissingGroups": { + "type": "boolean", + "description": "When true, deploy creates missing Entra security groups referenced by display name." + }, + "skipIfExists": { + "type": "boolean", + "description": "When true, skip site/Team creation (and further changes) if a matching container already exists." + }, + "siteTemplates": { + "type": "array", + "description": "Ordered list of sites or Teams to provision.", + "items": { "$ref": "#/$defs/siteTemplate" } + }, + "GUID": { + "type": "string", + "description": "Template row key; set by the API on save." + }, + "CreatedBy": { "type": "string" }, + "CreatedOn": { "type": "string", "format": "date-time" }, + "UpdatedBy": { "type": "string" }, + "UpdatedOn": { "type": "string", "format": "date-time" } + }, + "$defs": { + "siteTemplate": { + "type": "object", + "required": ["displayName", "permissions"], + "properties": { + "displayName": { + "type": "string", + "minLength": 1, + "description": "Site or Team display name." + }, + "alias": { + "type": "string", + "description": "Optional alias / URL slug hint." + }, + "description": { + "type": "string" + }, + "siteType": { + "type": "string", + "enum": ["sharePoint", "teams"], + "description": "Per-card type unless template overrideSiteType is active." + }, + "language": { + "description": "SharePoint site language LCID as string, or 'default' for tenant root language. Select/autocomplete may store {label,value}. Ignored for Teams.", + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "properties": { + "label": { "type": "string" }, + "value": { "type": "string" } + } + } + ] + }, + "createAs": { + "description": "SharePoint path only: Team site or Communication site. Ignored for Teams. Select may store {label,value}.", + "oneOf": [ + { "type": "string", "enum": ["Team", "Communication"] }, + { + "type": "object", + "properties": { + "label": { "type": "string" }, + "value": { "type": "string", "enum": ["Team", "Communication"] } + } + } + ] + }, + "permissions": { + "type": "array", + "minItems": 1, + "description": "Root-level permission grants (required).", + "items": { "$ref": "#/$defs/permissionGrant" } + }, + "libraries": { + "type": "array", + "items": { "$ref": "#/$defs/library" } + } + } + }, + "library": { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "description": { "type": "string" }, + "permissions": { + "type": "array", + "items": { "$ref": "#/$defs/permissionGrant" } + } + } + }, + "permissionGrant": { + "type": "object", + "required": ["principal", "permissionLevel"], + "properties": { + "principal": { + "description": "Group display name, or autocomplete {label,value}.", + "oneOf": [ + { "type": "string", "minLength": 1 }, + { + "type": "object", + "properties": { + "label": { "type": "string" }, + "value": { "type": "string" } + } + } + ] + }, + "permissionLevel": { + "type": "string", + "enum": ["read", "contribute", "edit", "design", "fullControl"] + } + } + } + } + } +] diff --git a/src/data/standards.json b/src/data/standards.json index a698010ce141..af70959830dd 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -51,8 +51,12 @@ "name": "standards.CopilotSettings.allowWebSearch", "options": [ { "label": "Do not configure", "value": "donotconfigure" }, - { "label": "Enabled", "value": "1" }, - { "label": "Disabled", "value": "0" } + { "label": "Enabled in Microsoft 365 Copilot and Microsoft 365 Copilot Chat", "value": "2" }, + { "label": "Disabled in Microsoft 365 Copilot and Microsoft 365 Copilot Chat", "value": "1" }, + { + "label": "Disabled in Microsoft 365 Copilot Work mode, Enabled in Microsoft 365 Copilot Chat", + "value": "0" + } ] }, { @@ -378,8 +382,8 @@ "cat": "Global Standards", "tag": ["CIS M365 7.0.0 (1.3.6)", "CustomerLockBoxEnabled"], "appliesToTest": ["CIS_1_3_6"], - "helpText": "**Requires Entra ID P2.** Enables Customer Lockbox that offers an approval process for Microsoft support to access organization data", - "docsDescription": "**Requires Entra ID P2.** Customer Lockbox ensures that Microsoft can't access your content to do service operations without your explicit approval. Customer Lockbox ensures only authorized requests allow access to your organizations data.", + "helpText": "**Requires CustomerLockbox (E5, E7, A5, Purview Addon for BP, EDU or FL)** Enables Customer Lockbox that offers an approval process for Microsoft support to access organization data", + "docsDescription": "**Requires CustomerLockbox (E5, E7, A5, Purview Addon for BP, EDU or FL)** Customer Lockbox ensures that Microsoft can't access your content to do service operations without your explicit approval. Customer Lockbox ensures only authorized requests allow access to your organizations data.", "executiveText": "Requires explicit organizational approval before Microsoft support staff can access company data for service operations. This provides an additional layer of data protection and ensures the organization maintains control over who can access sensitive business information, even during technical support scenarios.", "addedComponent": [], "label": "Enable Customer Lockbox", @@ -1371,21 +1375,38 @@ "cat": "Entra (AAD) Standards", "tag": ["SMB1001 (2.5)"], "appliesToTest": ["SMB1001_2_5", "ZTNA21889"], - "helpText": "Sets the state of the registration campaign for the tenant", - "docsDescription": "Sets the state of the registration campaign for the tenant. If enabled nudges users to set up the Microsoft Authenticator during sign-in.", + "helpText": "Sets the state of the registration campaign for the tenant, including the targeted authentication method, snooze settings and include/exclude groups. Leave include/exclude blank to keep the groups currently configured in the tenant, or use 'AllUsers' to target all users.", + "docsDescription": "Sets the state of the registration campaign for the tenant. If enabled nudges users to set up the targeted authentication method (Microsoft Authenticator or a Passkey) during sign-in. Supports limiting the number of snoozes, and including or excluding specific groups (by display name).", "executiveText": "Prompts employees to set up multi-factor authentication during login, gradually improving the organization's security posture by encouraging adoption of stronger authentication methods. This helps achieve better security compliance without forcing immediate mandatory changes.", "addedComponent": [ { "type": "autoComplete", "multiple": false, "creatable": false, - "label": "Select value", + "label": "Registration campaign state", "name": "standards.NudgeMFA.state", "options": [ { "label": "Enabled", "value": "enabled" }, { "label": "Disabled", "value": "disabled" } ] }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "required": false, + "label": "Authentication method to nudge users to register (default is Microsoft Authenticator)", + "name": "standards.NudgeMFA.targetedAuthenticationMethod", + "options": [ + { "label": "Microsoft Authenticator", "value": "microsoftAuthenticator" }, + { "label": "Passkey (FIDO2)", "value": "fido2" } + ], + "condition": { + "field": "standards.NudgeMFA.state", + "compareType": "valueEq", + "compareValue": "enabled" + } + }, { "type": "number", "name": "standards.NudgeMFA.snoozeDurationInDays", @@ -1395,6 +1416,39 @@ "min": { "value": 0, "message": "Minimum value is 0" }, "max": { "value": 14, "message": "Maximum value is 14" } } + }, + { + "type": "switch", + "name": "standards.NudgeMFA.enforceRegistrationAfterAllowedSnoozes", + "label": "Limited number of snoozes (require registration after 3 snoozes)", + "defaultValue": true, + "condition": { + "field": "standards.NudgeMFA.state", + "compareType": "valueEq", + "compareValue": "enabled" + } + }, + { + "type": "textField", + "name": "standards.NudgeMFA.includeTargets", + "label": "Include groups (comma separated group names, 'AllUsers' for everyone, blank = keep current targets)", + "required": false, + "condition": { + "field": "standards.NudgeMFA.state", + "compareType": "valueEq", + "compareValue": "enabled" + } + }, + { + "type": "textField", + "name": "standards.NudgeMFA.excludeTargets", + "label": "Exclude groups (comma separated group names, blank = keep current exclusions)", + "required": false, + "condition": { + "field": "standards.NudgeMFA.state", + "compareType": "valueEq", + "compareValue": "enabled" + } } ], "label": "Sets the state for the request to setup Authenticator", @@ -1409,10 +1463,23 @@ "cat": "Entra (AAD) Standards", "tag": ["CISA (MS.AAD.21.1v1)", "SMB1001 (2.8)"], "appliesToTest": ["SMB1001_2_8", "ZTNA21868"], - "helpText": "Restricts M365 group creation to certain admin roles. This disables the ability to create Teams, SharePoint sites, Planner, etc", - "docsDescription": "Users by default are allowed to create M365 groups. This restricts M365 group creation to certain admin roles. This disables the ability to create Teams, SharePoint sites, Planner, etc", - "executiveText": "Restricts the creation of Microsoft 365 groups, Teams, and SharePoint sites to authorized administrators, preventing uncontrolled proliferation of collaboration spaces. This ensures proper governance, naming conventions, and resource management while maintaining oversight of all collaborative environments.", - "addedComponent": [], + "helpText": "Restricts M365 group creation to certain admin roles. This disables the ability to create Teams, SharePoint sites, Planner, etc. Optionally allows members of a specific security group to keep creating groups (GroupCreationAllowedGroupId).", + "docsDescription": "Users by default are allowed to create M365 groups. This restricts M365 group creation to certain admin roles. This disables the ability to create Teams, SharePoint sites, Planner, etc. Optionally, a security group can be named whose members remain allowed to create groups; the group is resolved by display name in each tenant and can be created automatically when it does not exist. When no group is named, the existing GroupCreationAllowedGroupId value in the tenant is left untouched.", + "executiveText": "Restricts the creation of Microsoft 365 groups, Teams, and SharePoint sites to authorized administrators, preventing uncontrolled proliferation of collaboration spaces. This ensures proper governance, naming conventions, and resource management while maintaining oversight of all collaborative environments. An approved group of designated users can optionally retain the ability to create groups.", + "addedComponent": [ + { + "type": "textField", + "name": "standards.DisableM365GroupUsers.AllowedGroupName", + "label": "Optional: name of the group whose members may still create M365 groups", + "required": false + }, + { + "type": "switch", + "name": "standards.DisableM365GroupUsers.CreateGroup", + "label": "Create the allowed group if it does not exist", + "required": false + } + ], "label": "Disable M365 Group creation by users", "impact": "Low Impact", "impactColour": "info", @@ -1564,6 +1631,33 @@ "recommendedBy": ["CIS", "CIPP"], "requiredCapabilities": ["AAD_PREMIUM", "AAD_PREMIUM_P2"] }, + { + "name": "standards.DisableInactiveUsers", + "cat": "Entra (AAD) Standards", + "tag": ["CMMC (IA.L2-3.5.6)", "NIST SP 800-171 (3.5.6)"], + "helpText": "Blocks login for cloud-only member users that have not signed in for a configurable number of days (minimum 30). Includes accounts that have never signed in when the account is older than the threshold. Hybrid (on-premises synced) users are skipped. Users without sign-in activity data are not disabled.", + "docsDescription": "Disables enabled Member user accounts after a defined period of inactivity (minimum 30 days), supporting CMMC IA.L2-3.5.6 / NIST SP 800-171 3.5.6. Inactivity is based on signInActivity.lastSuccessfulSignInDateTime. Accounts that have never signed in (signInActivity present but no successful sign-in) are included when createdDateTime is older than the threshold. Users missing signInActivity entirely are skipped so incomplete Graph data cannot cause accidental disables. Hybrid-synced (onPremisesSyncEnabled) users are skipped because Entra disable often will not stick. Recently re-enabled accounts (last 7 days) are also skipped. Values below 30 days are rejected at runtime.", + "executiveText": "Automatically disables unused employee accounts that have not signed in for a configured number of days, reducing risk from dormant accounts and supporting CMMC / NIST inactive-identifier requirements. Hybrid directory-synced accounts are left alone so on-premises identity remains the source of truth for those users.", + "addedComponent": [ + { + "type": "number", + "name": "standards.DisableInactiveUsers.days", + "required": true, + "defaultValue": 180, + "label": "Days of inactivity (minimum 30)", + "validators": { + "min": { "value": 30, "message": "Minimum value is 30" } + } + } + ], + "label": "Disable Member accounts that have not logged on for a number of days", + "impact": "High Impact", + "impactColour": "danger", + "addedDate": "2026-07-22", + "powershellEquivalent": "Get-MgUser -Property SignInActivity & Update-MgUser -AccountEnabled $false", + "recommendedBy": ["CIPP", "CMMC"], + "requiredCapabilities": ["AAD_PREMIUM", "AAD_PREMIUM_P2"] + }, { "name": "standards.OauthConsent", "cat": "Entra (AAD) Standards", @@ -1587,8 +1681,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 +1703,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 +1750,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", @@ -3158,6 +3263,21 @@ "powershellEquivalent": "Get-Mailbox & Update-MgUser", "recommendedBy": ["CIS", "CIPP"] }, + { + "name": "standards.TeamsDisableResourceAccounts", + "cat": "Teams Standards", + "tag": ["NIST CSF 2.0 (PR.AA-01)"], + "helpText": "Blocks sign-in for all Teams resource accounts used by Auto Attendants and Call Queues. Microsoft's guidance is to block sign-in for resource accounts as they do not require an interactive login to function.", + "docsDescription": "Teams resource accounts (the accounts backing Auto Attendants and Call Queues) do not require interactive sign-in to function. If sign-in is enabled and the password is reset, the account can be logged into directly, which presents a security risk. Microsoft's guidance is to block sign-in for these accounts. Accounts that are synced from on-premises AD are excluded, as account state is managed in the on-premises AD.", + "executiveText": "Prevents direct login to the service accounts that power phone system features like Auto Attendants and Call Queues. These accounts work without anyone signing into them, so blocking sign-in removes an unnecessary attack surface while keeping the phone system fully functional.", + "addedComponent": [], + "label": "Block sign-in for Teams resource accounts", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-07-17", + "powershellEquivalent": "Get-CsOnlineApplicationInstance & Update-MgUser", + "recommendedBy": ["Microsoft", "CIPP"] + }, { "name": "standards.DisableResourceMailbox", "cat": "Exchange Standards", @@ -4157,12 +4277,10 @@ "defaultValue": false }, { - "type": "autoComplete", - "multiple": true, - "creatable": true, + "type": "LanguageCodeMultiSelect", "required": false, "name": "standards.SpamFilterPolicy.LanguageBlockList", - "label": "Languages to block (uppercase ISO 639-1 two-letter)", + "label": "Languages to block (ISO 639-1 two-letter)", "condition": { "field": "standards.SpamFilterPolicy.EnableLanguageBlockList", "compareType": "is", @@ -4176,12 +4294,10 @@ "defaultValue": false }, { - "type": "autoComplete", - "multiple": true, - "creatable": true, + "type": "CountryCodeMultiSelect", "required": false, "name": "standards.SpamFilterPolicy.RegionBlockList", - "label": "Regions to block (uppercase ISO 3166-1 two-letter)", + "label": "Regions to block (ISO 3166-1 two-letter)", "condition": { "field": "standards.SpamFilterPolicy.EnableRegionBlockList", "compareType": "is", @@ -5063,6 +5179,7 @@ }, { "name": "standards.SPDirectSharing", + "deprecated": true, "cat": "SharePoint Standards", "tag": [], "helpText": "This standard has been deprecated in favor of the Default Sharing Link standard. ", @@ -5865,7 +5982,7 @@ { "type": "switch", "name": "standards.TeamsFederationConfiguration.AllowTeamsConsumer", - "label": "Allow users to communicate with other organizations" + "label": "Allow users to communicate with consumer Teams accounts" }, { "type": "autoComplete", @@ -6523,7 +6640,7 @@ "label": "Conditional Access Template", "cat": "Templates", "multiple": true, - "disabledFeatures": { "report": true, "warn": true, "remediate": false }, + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, "impact": "High Impact", "addedDate": "2023-12-30", "tag": [ @@ -7255,6 +7372,12 @@ "label": "Block Android if partner data unavailable", "defaultValue": false }, + { + "type": "switch", + "name": "standards.DefenderCompliancePolicy.grantMobileThreatDefensePartnerRole", + "label": "Grant MTD role to MDE on enrolled Android COBO/COPE devices", + "defaultValue": false + }, { "type": "switch", "name": "standards.DefenderCompliancePolicy.ConnectIos", @@ -7264,13 +7387,19 @@ { "type": "switch", "name": "standards.DefenderCompliancePolicy.ConnectIosCompliance", - "label": "Connect iOS 13.0+ (App-based MAM)", + "label": "Connect iOS/iPadOS devices for app protection policy evaluation (MAM)", "defaultValue": false }, { "type": "switch", "name": "standards.DefenderCompliancePolicy.appSync", - "label": "Enable App Sync for iOS", + "label": "Enable App Sync (sending application inventory) for iOS/iPadOS devices", + "defaultValue": false + }, + { + "type": "switch", + "name": "standards.DefenderCompliancePolicy.allowPartnerToCollectIosPersonalApplicationMetadata", + "label": "Send full application inventory data on personally-owned iOS/iPadOS devices", "defaultValue": false }, { @@ -7282,13 +7411,13 @@ { "type": "switch", "name": "standards.DefenderCompliancePolicy.allowPartnerToCollectIosCertificateMetadata", - "label": "Collect certificate metadata from iOS", + "label": "Enable Certificate Sync for iOS/iPadOS devices", "defaultValue": false }, { "type": "switch", "name": "standards.DefenderCompliancePolicy.allowPartnerToCollectIosPersonalCertificateMetadata", - "label": "Collect personal certificate metadata from iOS", + "label": "Send full certificate inventory data on personally-owned iOS/iPadOS devices", "defaultValue": false }, { @@ -8024,7 +8153,7 @@ "tag": ["CIS M365 7.0.0 (5.2.3.8)", "CIS M365 7.0.0 (5.2.3.9)"], "appliesToTest": ["CIS_5_2_3_8", "CIS_5_2_3_9"], "helpText": "**Requires Entra ID P1.** Configures the Entra ID Smart Lockout settings including lockout duration, lockout threshold, and on-premises integration mode.", - "docsDescription": "Configures the Entra ID Smart Lockout policy which protects against brute-force password attacks. Smart Lockout locks out bad actors who try to guess user passwords or use brute-force methods. It recognizes sign-ins from valid users and treats them differently from attackers. Settings include lockout duration (seconds), lockout threshold (failed attempts before lockout), and on-premises password protection mode (Audit or Enforced).", + "docsDescription": "Configures the Entra ID Smart Lockout policy which protects against brute-force password attacks. Smart Lockout locks out bad actors who try to guess user passwords or use brute-force methods. It recognizes sign-ins from valid users and treats them differently from attackers. Settings include lockout duration (seconds), lockout threshold (failed attempts before lockout), and on-premises password protection mode (Audit or Enforce).", "addedComponent": [ { "type": "number", @@ -8051,7 +8180,7 @@ "label": "On-Premises Mode", "options": [ { "label": "Audit", "value": "Audit" }, - { "label": "Enforced", "value": "Enforced" } + { "label": "Enforce", "value": "Enforce" } ] } ], @@ -8085,8 +8214,12 @@ { "type": "number", "name": "standards.SPOVersionControl.ExpireVersionsAfterDays", - "label": "Expire Versions After Days (0 = never, when auto trim is off)", - "default": 0 + "label": "Expire Versions After Days (0 = never, otherwise 30-36500, when auto trim is off)", + "default": 0, + "validators": { + "min": { "value": 0, "message": "Use 0 for never, or 30 or more days" }, + "max": { "value": 36500, "message": "Maximum value is 36500" } + } }, { "type": "switch", diff --git a/src/hooks/use-user-bookmarks.js b/src/hooks/use-user-bookmarks.js index 7427ea5c06f0..bbb977522942 100644 --- a/src/hooks/use-user-bookmarks.js +++ b/src/hooks/use-user-bookmarks.js @@ -1,9 +1,7 @@ -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useMemo } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { ApiGetCall, ApiPostCall } from "../api/ApiCall"; -const SETTINGS_STORAGE_KEY = "app.settings"; - const sanitizeBookmark = (bookmark) => { if (!bookmark || typeof bookmark !== "object") { return null; @@ -43,47 +41,6 @@ const normalizeBookmarks = (value) => { return []; }; -const getLocalStoredBookmarks = () => { - if (typeof window === "undefined") { - return []; - } - - try { - const restored = window.localStorage.getItem(SETTINGS_STORAGE_KEY); - if (!restored) { - return []; - } - - const parsed = JSON.parse(restored); - return normalizeBookmarks(parsed?.bookmarks); - } catch { - return []; - } -}; - -const clearLocalStoredBookmarks = () => { - if (typeof window === "undefined") { - return; - } - - try { - const restored = window.localStorage.getItem(SETTINGS_STORAGE_KEY); - if (!restored) { - return; - } - - const parsed = JSON.parse(restored); - if (!parsed || typeof parsed !== "object" || !Object.prototype.hasOwnProperty.call(parsed, "bookmarks")) { - return; - } - - delete parsed.bookmarks; - window.localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(parsed)); - } catch { - return; - } -}; - const getBookmarksFromSettings = (settingsData) => { if (!settingsData) { return []; @@ -102,8 +59,6 @@ const getBookmarksFromSettings = (settingsData) => { export const useUserBookmarks = () => { const queryClient = useQueryClient(); - const localMigrationComplete = useRef(false); - const localMigrationInFlight = useRef(false); const userSettings = ApiGetCall({ url: "/api/ListUserSettings", @@ -163,47 +118,10 @@ export const useUserBookmarks = () => { [persistBookmarks] ); - useEffect(() => { - if (localMigrationComplete.current || localMigrationInFlight.current) { - return; - } - - if (!auth.data?.clientPrincipal?.userDetails) { - return; - } - - if (bookmarks.length > 0) { - localMigrationComplete.current = true; - return; - } - - const localBookmarks = getLocalStoredBookmarks(); - if (localBookmarks.length === 0) { - localMigrationComplete.current = true; - return; - } - - localMigrationInFlight.current = true; - const didPost = persistBookmarks(localBookmarks, { - onSuccess: () => { - clearLocalStoredBookmarks(); - localMigrationInFlight.current = false; - localMigrationComplete.current = true; - }, - onError: () => { - localMigrationInFlight.current = false; - }, - }); - - if (!didPost) { - localMigrationInFlight.current = false; - } - }, [auth.data?.clientPrincipal?.userDetails, bookmarks.length, persistBookmarks]); - return { bookmarks, setBookmarks, isLoading: userSettings.isLoading, isSaving: saveBookmarksPost.isPending, }; -}; \ No newline at end of file +}; diff --git a/src/icons/iconly/bulk/sharepoint.js b/src/icons/iconly/bulk/sharepoint.js new file mode 100644 index 000000000000..dd088e8f78ef --- /dev/null +++ b/src/icons/iconly/bulk/sharepoint.js @@ -0,0 +1,81 @@ +import { useId } from "react"; + +/** + * SharePoint product mark — shapes from the Microsoft SharePoint logo, + * recolored to CIPP / CyberDrain navy (#003049 and lighter blues). + * Accepts MUI-like fontSize / sx for drop-in use next to @mui/icons-material icons. + */ +const sizeFromFontSize = (fontSize) => { + if (fontSize == null || fontSize === "inherit") return "1em"; + if (typeof fontSize === "number") return fontSize; + if (fontSize === "small") return 20; + if (fontSize === "medium") return 24; + if (fontSize === "large") return 35; + return fontSize; +}; + +const SharePoint = ({ fontSize = "inherit", sx, width, height, style, ...props }) => { + const gradientId = useId().replace(/:/g, ""); + const resolved = width ?? height ?? sizeFromFontSize(sx?.fontSize ?? fontSize); + return ( + + + + + + + + + + + + + + + + + + + ); +}; + +export default SharePoint; diff --git a/src/icons/iconly/bulk/teams.js b/src/icons/iconly/bulk/teams.js new file mode 100644 index 000000000000..09302046db7b --- /dev/null +++ b/src/icons/iconly/bulk/teams.js @@ -0,0 +1,108 @@ +import { useId } from "react"; + +/** + * Microsoft Teams product mark — shapes from the Teams logo, + * recolored to CIPP indigo (#635dff / #4338CA). + * Accepts MUI-like fontSize / sx for drop-in use next to @mui/icons-material icons. + */ +const sizeFromFontSize = (fontSize) => { + if (fontSize == null || fontSize === "inherit") return "1em"; + if (typeof fontSize === "number") return fontSize; + if (fontSize === "small") return 20; + if (fontSize === "medium") return 24; + if (fontSize === "large") return 35; + return fontSize; +}; + +const Teams = ({ fontSize = "inherit", sx, width, height, style, ...props }) => { + const gradientId = useId().replace(/:/g, ""); + const resolved = width ?? height ?? sizeFromFontSize(sx?.fontSize ?? fontSize); + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default Teams; 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..9b3e11c721e9 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', }, ], }, @@ -569,7 +585,7 @@ export const nativeMenuItems = [ }, { title: 'Device Management', - permissions: ['Endpoint.MEM.*'], + permissions: ['Endpoint.MEM.*', 'Endpoint.Device.*'], items: [ { title: 'Devices', @@ -680,6 +696,31 @@ export const nativeMenuItems = [ path: '/teams-share/sharepoint', permissions: ['Sharepoint.Admin.*'], }, + { + title: 'SharePoint Templates', + path: '/teams-share/sharepoint-templates', + 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: 'Permissions Report', + path: '/teams-share/permissions-report', + permissions: ['Sharepoint.Site.*'], + }, + { + title: 'External Users', + path: '/teams-share/external-users', + permissions: ['Sharepoint.Site.*'], + }, { title: 'Teams', permissions: ['Teams.Group.*'], @@ -790,7 +831,7 @@ export const nativeMenuItems = [ }, { title: 'Transport', - permissions: ['Exchange.TransportRule.*'], + permissions: ['Exchange.TransportRule.*', 'Exchange.Connector.*'], items: [ { title: 'Transport rules', @@ -818,7 +859,7 @@ export const nativeMenuItems = [ }, { title: 'Spamfilter', - permissions: ['Exchange.SpamFilter.*'], + permissions: ['Exchange.SpamFilter.*', 'Exchange.ConnectionFilter.*'], items: [ { title: 'Spamfilter', @@ -851,7 +892,7 @@ export const nativeMenuItems = [ }, { title: 'Resource Management', - permissions: ['Exchange.Equipment.*'], + permissions: ['Exchange.Equipment.*', 'Exchange.Room.*'], items: [ { title: 'Equipment', diff --git a/src/layouts/index.js b/src/layouts/index.js index f3c178556ff3..e448ef8e8901 100644 --- a/src/layouts/index.js +++ b/src/layouts/index.js @@ -180,6 +180,7 @@ export const Layout = (props) => { // check sub-items if (item.items && item.items.length > 0) { const filteredSubItems = filterItemsByRole(item.items).filter(Boolean) + if (filteredSubItems.length === 0) return null return { ...item, items: filteredSubItems } } @@ -262,7 +263,7 @@ export const Layout = (props) => { }) const alertsAPI = ApiGetCall({ - url: `/api/GetCippAlerts?localversion=${version?.data?.version}`, + url: `/api/GetCippAlerts?localversion=${encodeURIComponent(version?.data?.version)}`, queryKey: 'alertsDashboard', waiting: false, refetchOnMount: false, @@ -342,15 +343,13 @@ export const Layout = (props) => { - + {!setupCompleted && ( - - - - Setup has not been completed. - - - + + + Setup has not been completed. + + )} {(currentTenant === 'AllTenants' || !currentTenant) && !allTenantsSupport ? ( diff --git a/src/layouts/side-nav-bookmarks.js b/src/layouts/side-nav-bookmarks.js index 04f4a978609a..0ae0ec7abdec 100644 --- a/src/layouts/side-nav-bookmarks.js +++ b/src/layouts/side-nav-bookmarks.js @@ -522,16 +522,18 @@ export const SideNavBookmarks = ({ collapse = false }) => { )} - { - e.preventDefault(); - removeBookmark(bookmark.path); - }} - sx={{ p: "2px" }} - > - - + {!locked && ( + { + e.preventDefault(); + removeBookmark(bookmark.path); + }} + sx={{ p: "2px" }} + > + + + )} diff --git a/src/layouts/side-nav.js b/src/layouts/side-nav.js index 1dadfca01577..12e05e4d3efb 100644 --- a/src/layouts/side-nav.js +++ b/src/layouts/side-nav.js @@ -135,15 +135,28 @@ export const SideNav = (props) => { let targetScrollTop = el.scrollTop let animating = false + let lastWrite = null const animate = () => { + if (!animating) { + return + } const diff = targetScrollTop - el.scrollTop - if (Math.abs(diff) < 0.5) { - el.scrollTop = targetScrollTop + + // browsers can round to device pixels + if (Math.abs(diff) < 1) { + animating = false + return + } + const before = el.scrollTop + lastWrite = before + diff * 0.25 + el.scrollTop = lastWrite + if (el.scrollTop === before) { + // write clamped or rounded to a no-op, stop instead of spinning the raf loop + targetScrollTop = before animating = false return } - el.scrollTop += diff * 0.25 requestAnimationFrame(animate) } @@ -157,8 +170,25 @@ export const SideNav = (props) => { } } + // scrollbar drags, keyboard and touch move scrollTop outside the wheel path, + // resync the target so the easing loop doesn't fight them for the thumb. + // mid-animation, events matching our own write are the loop's echo, skip those + const handleScroll = () => { + if (animating && lastWrite !== null && Math.abs(el.scrollTop - lastWrite) < 1) { + return + } + targetScrollTop = el.scrollTop + animating = false + } + el.addEventListener('wheel', handleWheel, { passive: false }) - return () => el.removeEventListener('wheel', handleWheel) + el.addEventListener('scroll', handleScroll, { passive: true }) + return () => { + // queued animate() exits on guard, stopping RAF chain + animating = false + el.removeEventListener('wheel', handleWheel) + el.removeEventListener('scroll', handleScroll) + } }, []) // Preprocess items to mark which should be open diff --git a/src/layouts/top-nav.js b/src/layouts/top-nav.js index 10bdcefd9581..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, @@ -54,7 +55,7 @@ export const TopNav = (props) => { const mdDown = useMediaQuery((theme) => theme.breakpoints.down('md')) const showPopoverBookmarks = settings.bookmarkPopover === true const reorderMode = settings.bookmarkReorderMode || 'arrows' - const locked = settings.bookmarkLocked ?? false + const locked = settings.bookmarkLocked ?? true const handleThemeSwitch = useCallback(() => { const themeName = settings.currentTheme?.value === 'light' ? 'dark' : 'light' settings.handleUpdate({ @@ -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 && ( <> @@ -590,18 +595,13 @@ export const TopNav = (props) => { )} - {!(reorderMode === 'drag' && locked) && ( + {!locked && ( { e.preventDefault() - if (locked) { - triggerLockFlash() - return - } removeBookmark(bookmark.path) }} - sx={{ ...(locked && { opacity: 0.4 }) }} > @@ -628,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 + + + { setDateLocale(resolvedLocale) }, []) - const excludeQueryKeys = ['authmeswa', 'alertsDashboard'] + // authmecipp not persisted, stale clientPrincipal:null flashes 401 on post-login reload + const excludeQueryKeys = ['authmeswa', 'authmecipp', 'alertsDashboard'] // 👇 Persist TanStack Query cache to localStorage useEffect(() => { @@ -162,7 +163,7 @@ const App = (props) => { persister: localStoragePersister, maxAge: 1000 * 60 * 60 * 24, // 24 hours staleTime: 1000 * 60 * 5, // optional: 5 minutes - buster: 'v1', + buster: 'v2', dehydrateOptions: { shouldDehydrateQuery: (query) => { const queryIsReadyForPersistence = query.state.status === 'success' @@ -220,18 +221,18 @@ const App = (props) => { id: 'bug-report', icon: , name: 'Report Bug', - href: 'https://github.com/KelvinTegelaar/CIPP/issues/new?template=bug.yml', + href: 'https://github.com/CyberDrain/CIPP/issues/new?template=bug.yml', onClick: () => - window.open('https://github.com/KelvinTegelaar/CIPP/issues/new?template=bug.yml', '_blank'), + window.open('https://github.com/CyberDrain/CIPP/issues/new?template=bug.yml', '_blank'), }, { id: 'feature-request', icon: , name: 'Request Feature', - href: 'https://github.com/KelvinTegelaar/CIPP/issues/new?template=feature.yml', + href: 'https://github.com/CyberDrain/CIPP/issues/new?template=feature.yml', onClick: () => window.open( - 'https://github.com/KelvinTegelaar/CIPP/issues/new?template=feature.yml', + 'https://github.com/CyberDrain/CIPP/issues/new?template=feature.yml', '_blank' ), }, diff --git a/src/pages/cipp/advanced/super-admin/cipp-users.js b/src/pages/cipp/advanced/super-admin/cipp-users.js index 32f0534825b2..ad3b6097c147 100644 --- a/src/pages/cipp/advanced/super-admin/cipp-users.js +++ b/src/pages/cipp/advanced/super-admin/cipp-users.js @@ -14,9 +14,15 @@ const Page = () => { Manage users who can access CIPP. Users are automatically synced from your partner tenant every 15 minutes based on Entra group memberships configured on the CIPP Roles page. You can also manually add users or assign additional roles — manual assignments - are preserved independently and will not be overwritten by the sync. Users not in this - list can still log in if "Allow All Tenant Users" is enabled, but they will - only receive default (authenticated) permissions. + are preserved independently and will not be overwritten by the sync. Users assigned the + superadmin role have full access to CIPP and all other permissions applied will be ignored. + You must have at least one superadmin user in CIPP at all times, and you cannot remove the + superadmin role from a user if they are the only superadmin. If you have only one superadmin + and need to change who it is, first assign another user the superadmin role, then you can + remove the superadmin role from the original user. To allow users from outside your partner tenant + to access CIPP, you can add them as guest users in your partner tenant and assign them the + appropriate roles in CIPP or enable the multi tenant mode in the CIPP SSO tab and add the users + to the list below without needing to add them as guest users in your tenant. diff --git a/src/pages/cipp/advanced/super-admin/custom-domains.js b/src/pages/cipp/advanced/super-admin/custom-domains.js new file mode 100644 index 000000000000..5dea28ed3b49 --- /dev/null +++ b/src/pages/cipp/advanced/super-admin/custom-domains.js @@ -0,0 +1,21 @@ +import { Container } from "@mui/material"; +import { TabbedLayout } from "../../../../layouts/TabbedLayout"; +import { Layout as DashboardLayout } from "../../../../layouts/index.js"; +import tabOptions from "./tabOptions"; +import { CippAppServiceDomains } from "../../../../components/CippSettings/CippAppServiceDomains"; + +const Page = () => { + return ( + + + + ); +}; + +Page.getLayout = (page) => ( + + {page} + +); + +export default Page; diff --git a/src/pages/cipp/advanced/super-admin/sam-app-permissions.js b/src/pages/cipp/advanced/super-admin/sam-app-permissions.js index 3930c51811ff..19e26b4143cb 100644 --- a/src/pages/cipp/advanced/super-admin/sam-app-permissions.js +++ b/src/pages/cipp/advanced/super-admin/sam-app-permissions.js @@ -2,15 +2,21 @@ import { TabbedLayout } from "../../../../layouts/TabbedLayout"; import { Layout as DashboardLayout } from "../../../../layouts/index.js"; import tabOptions from "./tabOptions"; import { useForm } from "react-hook-form"; +import { useState } from "react"; import { ApiGetCall, ApiPostCall } from "../../../../api/ApiCall"; import CippAppPermissionBuilder from "../../../../components/CippComponents/CippAppPermissionBuilder"; import CippPageCard from "../../../../components/CippCards/CippPageCard"; -import { Alert, CardContent, Skeleton, Stack, Typography } from "@mui/material"; -import { WarningAmberOutlined } from "@mui/icons-material"; +import { CippApiResults } from "../../../../components/CippComponents/CippApiResults"; +import { ConfirmationDialog } from "../../../../components/confirmation-dialog"; +import { Alert, Button, CardContent, Skeleton, Stack, SvgIcon, Typography } from "@mui/material"; +import { SettingsBackupRestore, WarningAmberOutlined } from "@mui/icons-material"; const Page = () => { const pageTitle = "SAM App Permissions"; + const [resetKey, setResetKey] = useState(0); + const [resetDialogOpen, setResetDialogOpen] = useState(false); + const formControl = useForm({ mode: "onBlur", }); @@ -25,6 +31,15 @@ const Page = () => { urlFromData: true, }); + const resetPermissions = ApiPostCall({ + relatedQueryKeys: ["execSamAppPermissions"], + onResult: () => { + // Remount the builder so it re-seeds its internal state from the freshly defaulted data + setResetKey((prev) => prev + 1); + execSamAppPermissions.refetch(); + }, + }); + const handleUpdatePermissions = (data) => { updatePermissions.mutate({ url: "/api/ExecSAMAppPermissions?Action=Update", @@ -33,18 +48,46 @@ const Page = () => { }); }; + const handleResetToCippDefaults = () => { + resetPermissions.mutate({ + url: "/api/ExecSAMAppPermissions?Action=Reset", + data: {}, + }); + }; + return ( - + - This is an advanced setting that allows you to modify the permissions used for CPV consent on customer tenants. + + This is an advanced setting that allows you to modify the permissions used for CPV + consent on customer tenants. + }> - This functionality is in beta and should be treated as such. Removing permissions from - the CIPP-SAM app is not advised. + The default CIPP-SAM permissions are always applied and cannot be removed - you + can only layer additional permissions on top of them. Use "Reset to CIPP Defaults" + to remove all additional permissions and return to the built-in defaults. + + + + {execSamAppPermissions.isLoading && } {execSamAppPermissions.isSuccess && ( { )} + setResetDialogOpen(false)} + title="Reset to CIPP Defaults" + variant="warning" + message="This removes all additional permissions you have layered on top of the CIPP-SAM defaults and returns the saved permission set to the built-in CIPP manifest defaults. The default permissions themselves are unaffected. You will need to complete a Permissions repair from the Permissions page, then complete a CPV refresh to finalise the chnages. Continue?" + onConfirm={() => { + handleResetToCippDefaults(); + setResetDialogOpen(false); + }} + /> ); }; diff --git a/src/pages/cipp/advanced/super-admin/tabOptions.json b/src/pages/cipp/advanced/super-admin/tabOptions.json index e0d9e9bad597..e6c9c420782b 100644 --- a/src/pages/cipp/advanced/super-admin/tabOptions.json +++ b/src/pages/cipp/advanced/super-admin/tabOptions.json @@ -43,5 +43,10 @@ "label": "Container Management", "path": "/cipp/advanced/super-admin/container", "icon": "Storage" + }, + { + "label": "Custom Domains", + "path": "/cipp/advanced/super-admin/custom-domains", + "icon": "Dns" } ] diff --git a/src/pages/cipp/integrations/configure.js b/src/pages/cipp/integrations/configure.js index 578317466c54..38f613b61e21 100644 --- a/src/pages/cipp/integrations/configure.js +++ b/src/pages/cipp/integrations/configure.js @@ -2,6 +2,7 @@ import { Alert, Box, Button, + Card, CardContent, Skeleton, Stack, diff --git a/src/pages/cipp/preferences.js b/src/pages/cipp/preferences.js index 2b5303fa8225..6b5644ac595a 100644 --- a/src/pages/cipp/preferences.js +++ b/src/pages/cipp/preferences.js @@ -215,7 +215,7 @@ const Page = () => { }, { 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. + + + + + + + + + + + + + + { + const tenant = useSettings().currentTenant + const queryKey = `CopilotUserActivity-${tenant}` + + const anonymized = useReportAnonymized({ + url: '/api/ListCopilotUsage', + data: { Type: 'UserDetail' }, + queryKey: queryKey, + fields: ['userPrincipalName', 'displayName'], + }) + return ( } simpleColumns={[ 'userPrincipalName', 'displayName', diff --git a/src/pages/copilot/settings/index.js b/src/pages/copilot/settings/index.js index 29d984013327..0f8c018b2e99 100644 --- a/src/pages/copilot/settings/index.js +++ b/src/pages/copilot/settings/index.js @@ -14,6 +14,7 @@ const Page = () => { url: '/api/ExecCopilotSettings', icon: , data: { settingId: 'settingId' }, + condition: (row) => row.settingId !== 'microsoft.copilot.allowwebsearch', fields: [ { type: 'autoComplete', @@ -31,6 +32,44 @@ const Page = () => { confirmText: "Set '[setting]' to the selected state?", relatedQueryKeys: [queryKey], }, + { + // Web search is a three-state policy; its values match the config.office.com options + label: 'Set Status', + type: 'POST', + url: '/api/ExecCopilotSettings', + icon: , + data: { settingId: 'settingId' }, + condition: (row) => row.settingId === 'microsoft.copilot.allowwebsearch', + fields: [ + { + type: 'autoComplete', + name: 'value', + label: 'Desired state', + multiple: false, + creatable: false, + options: [ + { + label: + 'Enabled in Microsoft 365 Copilot and Microsoft 365 Copilot Chat', + value: '2', + }, + { + label: + 'Disabled in Microsoft 365 Copilot and Microsoft 365 Copilot Chat', + value: '1', + }, + { + label: + 'Disabled in Microsoft 365 Copilot Work mode, Enabled in Microsoft 365 Copilot Chat', + value: '0', + }, + { label: 'Not configured', value: 'clear' }, + ], + }, + ], + confirmText: "Set '[setting]' to the selected state?", + relatedQueryKeys: [queryKey], + }, ] return ( diff --git a/src/pages/copilot/shadow-ai/index.js b/src/pages/copilot/shadow-ai/index.js index 8c7626b41b20..0d9e78b1323a 100644 --- a/src/pages/copilot/shadow-ai/index.js +++ b/src/pages/copilot/shadow-ai/index.js @@ -1,3 +1,4 @@ +import { useState } from 'react' import { Layout as DashboardLayout } from '../../../layouts/index.js' import { CippInfoBar } from '../../../components/CippCards/CippInfoBar' import { CippChartCard } from '../../../components/CippCards/CippChartCard' @@ -5,20 +6,179 @@ import { CippImageCard } from '../../../components/CippCards/CippImageCard' import { CippDataTable } from '../../../components/CippTable/CippDataTable' import { CippApiDialog } from '../../../components/CippComponents/CippApiDialog' import { CippOffCanvas } from '../../../components/CippComponents/CippOffCanvas' +import { ShadowAIReportButton } from '../../../components/ShadowAIReportButton' import { useDialog } from '../../../hooks/use-dialog' import { ApiGetCall } from '../../../api/ApiCall' import { useSettings } from '../../../hooks/use-settings' -import { Alert, Button, Container, Stack, SvgIcon, Typography } from '@mui/material' +import { + Alert, + Box, + Button, + Chip, + Container, + Divider, + Stack, + SvgIcon, + ToggleButton, + ToggleButtonGroup, + Typography, +} from '@mui/material' import { Grid } from '@mui/system' import { ArrowPathIcon, + CheckCircleIcon, CpuChipIcon, ComputerDesktopIcon, KeyIcon, ExclamationTriangleIcon, + NoSymbolIcon, UserGroupIcon, } from '@heroicons/react/24/outline' +const riskChipColor = (risk) => { + 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-template/add.jsx b/src/pages/email/administration/contacts-template/add.jsx index b05da569e29e..f43bf99d7721 100644 --- a/src/pages/email/administration/contacts-template/add.jsx +++ b/src/pages/email/administration/contacts-template/add.jsx @@ -37,27 +37,27 @@ const AddContactTemplates = () => { resetForm={true} customDataformatter={(values) => { return { - DisplayName: values.displayName, + displayName: values.displayName, hidefromGAL: values.hidefromGAL, email: values.email, - FirstName: values.firstName, - LastName: values.lastName, - Title: values.jobTitle, - StreetAddress: values.streetAddress, - PostalCode: values.postalCode, - City: values.city, - State: values.state, - CountryOrRegion: values.country?.value || values.country, - Company: values.companyName, + firstName: values.firstName, + lastName: values.lastName, + jobTitle: values.jobTitle, + streetAddress: values.streetAddress, + postalCode: values.postalCode, + city: values.city, + state: values.state, + country: values.country?.value || values.country, + companyName: values.companyName, mobilePhone: values.mobilePhone, - phone: values.businessPhone, + businessPhone: values.businessPhone, website: values.website, mailTip: values.mailTip, }; }} > - ); diff --git a/src/pages/email/administration/contacts-template/edit.jsx b/src/pages/email/administration/contacts-template/edit.jsx index 987e9f45a3bb..33f40120a1c2 100644 --- a/src/pages/email/administration/contacts-template/edit.jsx +++ b/src/pages/email/administration/contacts-template/edit.jsx @@ -8,8 +8,6 @@ import { ApiGetCall } from "../../../../api/ApiCall"; import countryList from "../../../../data/countryList.json"; import { useRouter } from "next/router"; -const countryLookup = new Map(countryList.map((country) => [country.Name, country.Code])); - const EditContactTemplate = () => { const router = useRouter(); const { id } = router.query; @@ -57,32 +55,33 @@ const EditContactTemplate = () => { const contact = Array.isArray(contactTemplateInfo.data) ? contactTemplateInfo.data[0] : contactTemplateInfo.data; - const address = contact.addresses?.[0] || {}; - const phones = contact.phones || []; - // Use Map for O(1) phone lookup - const phoneMap = new Map(phones.map((p) => [p.type, p.number])); + // The template is stored as a flat object (see Invoke-AddContactTemplates), so read the + // fields directly rather than treating it as a Microsoft Graph contact. + const countryEntry = contact.country + ? countryList.find((c) => c.Code === contact.country || c.Name === contact.country) + : null; return { ContactTemplateID: id || "", displayName: contact.displayName || "", - firstName: contact.givenName || "", - lastName: contact.surname || "", + firstName: contact.firstName || "", + lastName: contact.lastName || "", email: contact.email || "", hidefromGAL: contact.hidefromGAL || false, - streetAddress: address.street || "", - postalCode: address.postalCode || "", - city: address.city || "", - state: address.state || "", - country: address.countryOrRegion ? countryLookup.get(address.countryOrRegion) || "" : "", + streetAddress: contact.streetAddress || "", + postalCode: contact.postalCode || "", + city: contact.city || "", + state: contact.state || "", + country: countryEntry ? { label: countryEntry.Name, value: countryEntry.Code } : "", companyName: contact.companyName || "", - mobilePhone: phoneMap.get("mobile") || "", - businessPhone: phoneMap.get("business") || "", + mobilePhone: contact.mobilePhone || "", + businessPhone: contact.businessPhone || "", jobTitle: contact.jobTitle || "", website: contact.website || "", mailTip: contact.mailTip || "", }; - }, [contactTemplateInfo.isSuccess, contactTemplateInfo.data]); + }, [contactTemplateInfo.isSuccess, contactTemplateInfo.data, id]); // Use callback to prevent unnecessary re-renders const resetForm = useCallback(() => { @@ -96,27 +95,30 @@ const EditContactTemplate = () => { }, [resetForm]); // Memoize custom data formatter - const customDataFormatter = useCallback((values) => { - return { - ContactTemplateID: id, - DisplayName: values.displayName, - hidefromGAL: values.hidefromGAL, - email: values.email, - FirstName: values.firstName, - LastName: values.lastName, - Title: values.jobTitle, - StreetAddress: values.streetAddress, - PostalCode: values.postalCode, - City: values.city, - State: values.state, - CountryOrRegion: values.country?.value || values.country, - Company: values.companyName, - mobilePhone: values.mobilePhone, - phone: values.businessPhone, - website: values.website, - mailTip: values.mailTip, - }; - }); + const customDataFormatter = useCallback( + (values) => { + return { + ContactTemplateID: id, + displayName: values.displayName, + hidefromGAL: values.hidefromGAL, + email: values.email, + firstName: values.firstName, + lastName: values.lastName, + jobTitle: values.jobTitle, + streetAddress: values.streetAddress, + postalCode: values.postalCode, + city: values.city, + state: values.state, + country: values.country?.value || values.country, + companyName: values.companyName, + mobilePhone: values.mobilePhone, + businessPhone: values.businessPhone, + website: values.website, + mailTip: values.mailTip, + }; + }, + [id] + ); const contactTemplate = Array.isArray(contactTemplateInfo.data) ? contactTemplateInfo.data[0] diff --git a/src/pages/email/administration/contacts-template/index.jsx b/src/pages/email/administration/contacts-template/index.jsx index d24604c9a9af..8d5fc6d69239 100644 --- a/src/pages/email/administration/contacts-template/index.jsx +++ b/src/pages/email/administration/contacts-template/index.jsx @@ -81,7 +81,14 @@ const Page = () => { target: "_self", }, ]; - const simpleColumns = ["name", "contactTemplateName", "GUID"]; + const simpleColumns = [ + "displayName", + "email", + "companyName", + "jobTitle", + "hidefromGAL", + "GUID", + ]; return ( { simpleColumns={simpleColumns} cardButton={ <> - + ), 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/users/user/index.jsx b/src/pages/identity/administration/users/user/index.jsx index cce0e8f472fd..5947b91606c6 100644 --- a/src/pages/identity/administration/users/user/index.jsx +++ b/src/pages/identity/administration/users/user/index.jsx @@ -56,6 +56,7 @@ const SignInLogsDialog = ({ open, onClose, userId, tenantFilter }) => { { tenantFilter: tenantFilter, top: 50, }, - queryKey: `ListSignIns-${userId}`, }} /> 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 ( { return { ...data } }, confirmText: 'Are you sure you want to create a template based on this DLP policy?', + hideBulk: true, }, { label: 'Enable Policy', diff --git a/src/pages/security/compliance/labels-templates/index.js b/src/pages/security/compliance/labels-templates/index.js index 78477f7735b5..092d076f5370 100644 --- a/src/pages/security/compliance/labels-templates/index.js +++ b/src/pages/security/compliance/labels-templates/index.js @@ -72,9 +72,9 @@ const Page = () => { const offCanvas = { extendedInfoFields: [ - "name", "DisplayName", - "comments", + "Name", + "Comment", "ContentType", "EncryptionEnabled", "GUID", @@ -82,7 +82,7 @@ const Page = () => { actions: actions, }; - const simpleColumns = ["name", "DisplayName", "comments", "ContentType", "EncryptionEnabled", "GUID"]; + const simpleColumns = ["DisplayName", "Name", "Comment", "ContentType", "EncryptionEnabled", "GUID"]; return ( { return { ...data } }, 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', @@ -45,6 +68,7 @@ const Page = () => { 'Tooltip', 'ParentId', 'ContentType', + 'Color', 'EncryptionEnabled', 'EncryptionProtectionType', 'ContentMarkingHeaderEnabled', @@ -61,6 +85,7 @@ const Page = () => { const simpleColumns = [ 'DisplayName', 'Name', + 'Color', 'ContentType', 'EncryptionEnabled', 'ContentMarkingHeaderEnabled', diff --git a/src/pages/security/compliance/retention/index.js b/src/pages/security/compliance/retention/index.js index 940d88b4eb0a..3efd34798877 100644 --- a/src/pages/security/compliance/retention/index.js +++ b/src/pages/security/compliance/retention/index.js @@ -20,6 +20,7 @@ const Page = () => { url: '/api/AddRetentionCompliancePolicyTemplate', data: { Identity: 'Name' }, confirmText: 'Are you sure you want to create a template based on this retention policy?', + hideBulk: true, }, { label: 'Enable Policy', 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/defender/deployment/index.js b/src/pages/security/defender/deployment/index.js index b1012ad0f3a7..5567ba24d576 100644 --- a/src/pages/security/defender/deployment/index.js +++ b/src/pages/security/defender/deployment/index.js @@ -138,6 +138,12 @@ const DeployDefenderForm = () => { formControl={formControl} /> + @@ -249,7 +255,7 @@ const DeployDefenderForm = () => { > @@ -259,6 +265,20 @@ const DeployDefenderForm = () => { name="Compliance.AppSync" formControl={formControl} /> + + + { /> - + field="Compliance.allowPartnerToCollectIosCertificateMetadata" + compareType="is" + compareValue={true} + action="disable" + > + + diff --git a/src/pages/security/incidents/list-check-alerts/index.js b/src/pages/security/incidents/list-check-alerts/index.js index db56faf0454e..2586b6ad7167 100644 --- a/src/pages/security/incidents/list-check-alerts/index.js +++ b/src/pages/security/incidents/list-check-alerts/index.js @@ -14,7 +14,7 @@ const Page = () => { docs.check.tech {" "} - or install the plugin now: + or install the plugin now: { > Microsoft Edge {" "} - | + | { - const d = new Date(); - d.setDate(d.getDate() - 30); - d.setHours(0, 0, 0, 0); - return Math.floor(d.getTime() / 1000); -})(); + const d = new Date() + d.setDate(d.getDate() - 30) + d.setHours(0, 0, 0, 0) + return Math.floor(d.getTime() / 1000) +})() const Page = () => { - const pageTitle = "Incidents List"; - const userSettingsDefaults = useSettings(); + const pageTitle = 'Incidents List' + const userSettingsDefaults = useSettings() - const formControl = useForm({ defaultValues: { startDate: defaultStartDate, endDate: null } }); - const [expanded, setExpanded] = useState(false); - const [filterEnabled, setFilterEnabled] = useState(true); + const formControl = useForm({ defaultValues: { startDate: defaultStartDate, endDate: null } }) + const [expanded, setExpanded] = useState(false) + const [filterEnabled, setFilterEnabled] = useState(true) const [startDate, setStartDate] = useState( - new Date(defaultStartDate * 1000).toISOString().split("T")[0].replace(/-/g, ""), - ); - const [endDate, setEndDate] = useState(null); + new Date(defaultStartDate * 1000).toISOString().split('T')[0].replace(/-/g, '') + ) + const [endDate, setEndDate] = useState(null) const onSubmit = (data) => { setStartDate( data.startDate - ? new Date(data.startDate * 1000).toISOString().split("T")[0].replace(/-/g, "") - : null, - ); + ? new Date(data.startDate * 1000).toISOString().split('T')[0].replace(/-/g, '') + : null + ) setEndDate( data.endDate - ? new Date(data.endDate * 1000).toISOString().split("T")[0].replace(/-/g, "") - : null, - ); - setFilterEnabled(data.startDate !== null || data.endDate !== null); - setExpanded(false); - }; + ? new Date(data.endDate * 1000).toISOString().split('T')[0].replace(/-/g, '') + : null + ) + setFilterEnabled(data.startDate !== null || data.endDate !== null) + setExpanded(false) + } const clearFilters = () => { - formControl.reset({ startDate: null, endDate: null }); - setFilterEnabled(false); - setStartDate(null); - setEndDate(null); - setExpanded(false); - }; + formControl.reset({ startDate: null, endDate: null }) + setFilterEnabled(false) + setStartDate(null) + setEndDate(null) + setExpanded(false) + } const fmt = (yyyymmdd) => yyyymmdd ? new Date( - yyyymmdd.replace(/(\d{4})(\d{2})(\d{2})/, "$1-$2-$3") + "T00:00:00", + yyyymmdd.replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3') + 'T00:00:00' ).toLocaleDateString() - : null; + : null const tableFilter = ( setExpanded(v)}> }> - + @@ -82,7 +82,7 @@ const Page = () => { : startDate ? `Date Filter: From ${fmt(startDate)}` : `Date Filter: Up to ${fmt(endDate)}` - : "Date Filter"} + : 'Date Filter'} @@ -127,89 +127,123 @@ const Page = () => { - ); + ) // Define actions for incidents const actions = [ { - label: "Assign to self", - type: "POST", + label: 'Assign to self', + type: 'POST', icon: , - url: "/api/ExecSetSecurityIncident", + url: '/api/ExecSetSecurityIncident', data: { - GUID: "Id", + GUID: 'Id', + AssignToSelf: true, }, - confirmText: "Are you sure you want to assign this incident to yourself?", + confirmText: 'Are you sure you want to assign this incident to yourself?', }, { - label: "Set status to active", - type: "POST", + label: 'Set status to active', + type: 'POST', icon: , - url: "/api/ExecSetSecurityIncident", + url: '/api/ExecSetSecurityIncident', data: { - GUID: "Id", - Status: "!active", - Assigned: "AssignedTo", + GUID: 'Id', + Status: '!active', }, - confirmText: "Are you sure you want to set the status to active?", + confirmText: 'Are you sure you want to set the status to active?', }, { - label: "Set status to in progress", - type: "POST", + label: 'Set status to in progress', + type: 'POST', icon: , - url: "/api/ExecSetSecurityIncident", + url: '/api/ExecSetSecurityIncident', data: { - GUID: "Id", - Status: "!inProgress", - Assigned: "AssignedTo", + GUID: 'Id', + Status: '!inProgress', }, - confirmText: "Are you sure you want to set the status to in progress?", + confirmText: 'Are you sure you want to set the status to in progress?', }, { - label: "Set status to resolved", - type: "POST", + label: 'Set status to resolved', + type: 'POST', icon: , - url: "/api/ExecSetSecurityIncident", + url: '/api/ExecSetSecurityIncident', data: { - GUID: "Id", - Status: "!resolved", - Assigned: "AssignedTo", + GUID: 'Id', + Status: '!resolved', }, - confirmText: "Are you sure you want to set the status to resolved?", + fields: [ + { + type: 'textField', + name: 'Comment', + label: 'Resolving comment (optional)', + multiline: true, + rows: 3, + }, + ], + confirmText: 'Are you sure you want to set the status to resolved?', }, - ]; + { + label: 'Set severity', + type: 'POST', + icon: , + url: '/api/ExecSetSecurityIncident', + data: { + GUID: 'Id', + }, + fields: [ + { + type: 'autoComplete', + name: 'Severity', + label: 'Severity', + multiple: false, + creatable: false, + options: [ + { value: 'informational', label: 'Informational' }, + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, + ], + validators: { required: 'Severity is required' }, + }, + ], + confirmText: 'Select the severity to set for this incident.', + }, + ] // Define off-canvas details const offCanvas = { extendedInfoFields: [ - "Created", - "Updated", - "Tenant", - "Id", - "RedirectId", - "DisplayName", - "Status", - "Severity", - "AssignedTo", - "Classification", - "Determination", - "IncidentUrl", - "Tags", + 'Created', + 'Updated', + 'Tenant', + 'Id', + 'RedirectId', + 'DisplayName', + 'Status', + 'Severity', + 'AssignedTo', + 'Classification', + 'Determination', + 'IncidentUrl', + 'Tags', ], actions: actions, - }; + } // Simplified columns for the table const simpleColumns = [ - "Created", - "Tenant", - "Id", - "DisplayName", - "Status", - "Severity", - "Tags", - "IncidentUrl", - ]; + 'Created', + 'Tenant', + 'Id', + 'DisplayName', + 'Status', + 'Severity', + 'AssignedTo', + 'Tags', + 'IncidentUrl', + ] return ( { queryKey={`ExecIncidentsList-${userSettingsDefaults.currentTenant}-${startDate}-${endDate}`} apiData={{ StartDate: startDate, EndDate: endDate }} /> - ); -}; + ) +} -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page} -export default Page; +export default Page diff --git a/src/pages/security/reports/cve-report/index.js b/src/pages/security/reports/cve-report/index.js new file mode 100644 index 000000000000..b0d8317374d8 --- /dev/null +++ b/src/pages/security/reports/cve-report/index.js @@ -0,0 +1,30 @@ +import { Layout as DashboardLayout } from "../../../../layouts/index.js"; +import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; + +const Page = () => { + return ( + + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; diff --git a/src/pages/teams-share/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/onedrive/index.js b/src/pages/teams-share/onedrive/index.js index 7c06e88f1441..2eb84bd942c1 100644 --- a/src/pages/teams-share/onedrive/index.js +++ b/src/pages/teams-share/onedrive/index.js @@ -74,7 +74,16 @@ const Page = () => { multiple: false, creatable: false, api: { - url: '/api/listUsers', + url: '/api/ListGraphRequest', + dataKey: 'Results', + data: { + Endpoint: 'users', + manualPagination: true, + $select: 'id,userPrincipalName,displayName', + $count: true, + $orderby: 'displayName', + $top: 999, + }, labelField: (onedriveAccessUser) => `${onedriveAccessUser.displayName} (${onedriveAccessUser.userPrincipalName})`, valueField: 'userPrincipalName', diff --git a/src/pages/teams-share/permissions-report/index.js b/src/pages/teams-share/permissions-report/index.js new file mode 100644 index 000000000000..61d24b785e0e --- /dev/null +++ b/src/pages/teams-share/permissions-report/index.js @@ -0,0 +1,323 @@ +import { useRef, useState } from 'react' +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 { CippMultiQueueTracker } from '../../../components/CippComponents/CippMultiQueueTracker' +import { CippQueryRefreshButton } from '../../../components/CippComponents/CippQueryRefreshButton' +import { PermissionsReportButton } from '../../../components/CippPdf/PermissionsReportButton' +import { useDialog } from '../../../hooks/use-dialog' +import { ApiGetCall } from '../../../api/ApiCall' +import { useSettings } from '../../../hooks/use-settings' +import { Alert, Button, Container, Stack, SvgIcon, Typography } from '@mui/material' +import { Grid } from '@mui/system' +import { + BuildingOfficeIcon, + CloudArrowDownIcon, + DocumentTextIcon, + ExclamationTriangleIcon, + FolderIcon, + GlobeAltIcon, + KeyIcon, + LockOpenIcon, + UserPlusIcon, +} from '@heroicons/react/24/outline' + +// This report is compiled from one cache, so Sync starts a single queue. +const syncRows = [{ Name: 'SharePointPermissions' }] + +// Stable empty fallback: CippDataTable syncs its `data` prop by reference, so a fresh [] on every +// render would re-trigger that sync in a loop. +const EMPTY_ROWS = [] + +const Page = () => { + const currentTenant = useSettings().currentTenant + const syncDialog = useDialog() + const [syncQueueIds, setSyncQueueIds] = useState([]) + const newSyncRunRef = useRef(false) + const queryKey = `ListSharePointPermissions-${currentTenant}` + + const permissions = ApiGetCall({ + url: '/api/ListSharePointPermissions', + data: { tenantFilter: currentTenant }, + queryKey: queryKey, + waiting: !!currentTenant && currentTenant !== 'AllTenants', + }) + + const data = permissions.data ?? {} + const summary = data.summary ?? {} + const assignments = data.assignments ?? EMPTY_ROWS + const byPermissionLevel = data.byPermissionLevel ?? [] + const byPrincipalType = data.byPrincipalType ?? [] + const topSitesByUniqueLibraries = data.topSitesByUniqueLibraries ?? [] + const skippedSites = data.skippedSites ?? [] + const needsSync = permissions.isSuccess && !summary.permissionsSynced + const showCharts = permissions.isFetching || byPermissionLevel.length > 0 + + const permissionFilters = [ + { + // Everyone / Everyone except external users / All Users: reachable by the whole tenant + // regardless of what the site's membership says. Filters on the boolean rather than + // broadClaim because the three claim names share no common substring. + filterName: 'Tenant-Wide Grants', + value: [{ id: 'isTenantWide', value: 'Yes' }], + type: 'column', + }, + { + filterName: 'External Grants', + value: [{ id: 'isGuest', value: 'Yes' }], + type: 'column', + }, + { + filterName: 'Full Control', + value: [{ id: 'permissionLevel', value: 'Full Control' }], + type: 'column', + }, + { + // Libraries that no longer inherit, so site-level changes no longer reach them. + filterName: 'Detached Libraries', + value: [{ id: 'appliesTo', value: 'This library only' }], + type: 'column', + }, + { + filterName: 'Site Level', + value: [{ id: 'appliesTo', value: 'Whole site' }], + type: 'column', + }, + ] + + return ( + + + {currentTenant === 'AllTenants' ? ( + + + + ) : ( + <> + + + + {summary.lastDataRefresh + ? `Last data refresh: ${new Date(summary.lastDataRefresh.UtcDateTime ?? summary.lastDataRefresh).toLocaleString()}` + : ''} + + + + + + + + + {needsSync && ( + + No cached permission data found for this tenant yet. Click "Sync data" to read + site and document library permissions. This scan reads permissions per library + rather than per file, so it is far quicker than the sharing link scan. + + )} + {skippedSites.length > 0 && ( + + {skippedSites.length} site(s) could not be read during the last scan. Results from + an earlier successful scan are kept where they exist, so those rows may be out of + date; sites never read successfully contribute nothing at all. + + )} + + + + , + name: 'Tenant-Wide Grants', + data: `${summary.broadClaimGrants ?? 0}`, + color: 'error', + }, + { + icon: , + name: 'External Grants', + data: `${summary.externalGrants ?? 0}`, + color: 'warning', + }, + { + icon: , + name: 'Direct Full Control', + data: `${summary.directFullControlGrants ?? 0}`, + color: 'warning', + }, + { + icon: , + name: 'Detached Libraries', + data: `${summary.uniquePermissionLibraries ?? 0}`, + }, + ]} + /> + + + + , + name: 'Sites Scanned', + data: `${summary.sitesScanned ?? 0}`, + }, + { + icon: , + name: 'Libraries Scanned', + data: `${summary.librariesScanned ?? 0}`, + }, + { + icon: , + name: 'Permission Assignments', + data: `${summary.totalAssignments ?? 0}`, + }, + { + icon: , + name: 'Sites Not Read', + data: `${summary.sitesSkipped ?? 0}`, + color: summary.sitesSkipped > 0 ? 'warning' : undefined, + }, + ]} + /> + + + {showCharts && ( + <> + + item.level)} + chartSeries={byPermissionLevel.map((item) => item.grants)} + totalLabel="Grants" + /> + + + item.type)} + chartSeries={byPrincipalType.map((item) => item.grants)} + totalLabel="Grants" + /> + + + item.site)} + chartSeries={topSitesByUniqueLibraries.map((item) => item.libraries)} + totalLabel="Libraries" + /> + + + )} + + + + Applies To shows how far each permission reaches.{' '} + Whole site is a permission on the site itself, which every library that + still inherits also gets. This library only means that library was detached + and keeps its own permissions, so site-level changes no longer reach it. Libraries + that still inherit are not listed — their permissions are the site's, so + everything here is either the site's own permissions or a deliberate exception + to them. + + + + + { + const queueId = result?.Metadata?.QueueId + if (!queueId) return + if (newSyncRunRef.current) { + newSyncRunRef.current = false + setSyncQueueIds([queueId]) + return + } + setSyncQueueIds((previous) => + previous.includes(queueId) ? previous : [...previous, queueId] + ) + }, + }} + row={syncRows} + /> + + )} + + + ) +} + +Page.getLayout = (page) => {page} + +export default Page diff --git a/src/pages/teams-share/sharepoint-templates/add.js b/src/pages/teams-share/sharepoint-templates/add.js new file mode 100644 index 000000000000..1731f2ead8be --- /dev/null +++ b/src/pages/teams-share/sharepoint-templates/add.js @@ -0,0 +1,228 @@ +import { useEffect } from "react"; +import { useRouter } from "next/router"; +import { useForm, useWatch } from "react-hook-form"; +import { Box, Button, Container, IconButton, Stack, Tooltip, Typography } from "@mui/material"; +import { Grid } from "@mui/system"; +import { ArrowBack, InfoOutlined, Save } from "@mui/icons-material"; +import { Layout as DashboardLayout } from "../../../layouts/index.js"; +import { ApiGetCall, ApiPostCall } from "../../../api/ApiCall"; +import { CippHead } from "../../../components/CippComponents/CippHead"; +import CippFormComponent from "../../../components/CippComponents/CippFormComponent"; +import { CippApiResults } from "../../../components/CippComponents/CippApiResults"; +import CippButtonCard from "../../../components/CippCards/CippButtonCard"; +import { + CippSharePointTemplateBuilder, + CippSharePointTemplateBuilderSkeleton, + CippSharePointTemplateQuickStats, + CippSharePointTemplateQuickStatsSkeleton, + getSiteTemplateSaveIssues, + getSiteLanguageOption, + siteTemplateBlocksSave, + SHAREPOINT_TEMPLATE_ENGINE_VERSION, +} from "../../../components/CippComponents/CippSharePointTemplateBuilder"; + +const emptyTemplate = { + templateName: "", + templateEngineVersion: SHAREPOINT_TEMPLATE_ENGINE_VERSION, + siteType: "sharePoint", + overrideSiteType: false, + createMissingGroups: false, + skipIfExists: false, + siteTemplates: [], +}; + +const Page = () => { + const router = useRouter(); + const { template, copy } = router.query; + // Next may give query values as string | string[]. Treat only explicit copy=true as copy mode + // so title and save (TemplateId) cannot disagree. + const templateId = Array.isArray(template) ? template[0] : template; + const isCopy = copy === true || copy === "true"; + const isEdit = !!templateId && !isCopy; + const pageTitle = isCopy + ? "Copy SharePoint Template" + : isEdit + ? "Edit SharePoint Template" + : "Create SharePoint Template"; + + const formControl = useForm({ mode: "onChange", defaultValues: emptyTemplate }); + + const templateQuery = ApiGetCall({ + url: templateId ? `/api/ExecSharePointTemplate?Action=Get&TemplateId=${templateId}` : null, + queryKey: templateId ? `ExecSharePointTemplate-${templateId}` : null, + waiting: !!templateId, + // Edit/copy must never paint a stale cached template — always refetch on open. + staleTime: 0, + refetchOnMount: "always", + }); + const templateData = templateQuery.data; + // Treat in-flight refetch like loading so a previous cache cannot flash into the form. + const isLoadingTemplate = + !!templateId && (templateQuery.isLoading || templateQuery.isFetching); + + // Site-template fields that block Save (name, root perms, library names). Cards outline offenders in red. + const siteTemplatesValue = useWatch({ control: formControl.control, name: "siteTemplates" }); + const siteTemplatesBlockSave = (siteTemplatesValue || []).some(siteTemplateBlocksSave); + const siteTemplateSaveIssues = getSiteTemplateSaveIssues(siteTemplatesValue || []); + + const saveTemplate = ApiPostCall({ + // Wildcard: Get uses ExecSharePointTemplate-{id}, not the bare ExecSharePointTemplate key. + relatedQueryKeys: ["ListSharePointTemplates", "ExecSharePointTemplate-*"], + }); + + // Hydrate only after a fresh fetch finishes. Skip while isFetching so we never reset() from + // a stale cache entry that React Query still exposes during refetch. + useEffect(() => { + if (!templateId || templateQuery.isFetching) return; + const result = Array.isArray(templateData) ? templateData[0] : templateData?.Results; + if (!result) return; + const normalizeSiteType = (value) => + value === "teams" || value?.value === "teams" ? "teams" : "sharePoint"; + formControl.reset({ + templateName: isCopy ? `${result.templateName || ""} (Copy)` : result.templateName || "", + templateEngineVersion: SHAREPOINT_TEMPLATE_ENGINE_VERSION, + siteType: normalizeSiteType(result.siteType), + overrideSiteType: !!result.overrideSiteType, + createMissingGroups: !!result.createMissingGroups, + skipIfExists: !!result.skipIfExists, + siteTemplates: (result.siteTemplates || []).map((site) => ({ + ...site, + siteType: normalizeSiteType(site.siteType), + language: getSiteLanguageOption(site.language), + })), + }); + formControl.trigger(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [templateId, templateData, isCopy, templateQuery.isFetching, templateQuery.dataUpdatedAt]); + + const handleSubmit = (payload) => { + payload.templateEngineVersion = SHAREPOINT_TEMPLATE_ENGINE_VERSION; + if (isEdit) { + payload.TemplateId = templateId; + } + saveTemplate.mutate( + { + url: "/api/ExecSharePointTemplate?Action=Save", + data: payload, + queryKey: "ExecSharePointTemplate", + }, + { + onSuccess: () => { + router.push("/teams-share/sharepoint-templates"); + }, + } + ); + }; + + return ( + <> + + + + + + + + {pageTitle} + + + + + + + + {siteTemplatesBlockSave && ( + + {siteTemplateSaveIssues.map((issue) => ( +
  • {issue}
  • + ))} +
    + } + > + + + + + )} + + } + > + + + + + + + + + {isLoadingTemplate ? ( + + ) : ( + + )} + + + + + + {isLoadingTemplate ? ( + + ) : ( + + )} + + + + + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; diff --git a/src/pages/teams-share/sharepoint-templates/index.js b/src/pages/teams-share/sharepoint-templates/index.js new file mode 100644 index 000000000000..a381342cfb1b --- /dev/null +++ b/src/pages/teams-share/sharepoint-templates/index.js @@ -0,0 +1,96 @@ +import { Layout as DashboardLayout } from '../../../layouts/index.js' +import { CippTablePage } from '../../../components/CippComponents/CippTablePage.jsx' +import { CippSharePointTemplateDeployDrawer } from '../../../components/CippComponents/CippSharePointTemplateDeployDrawer.jsx' +import { usePermissions } from '../../../hooks/use-permissions' +import { PermissionButton } from '../../../utils/permissions.js' +import { Edit, ContentCopy, Delete, Add } from '@mui/icons-material' +import { Stack } from '@mui/system' +import Link from 'next/link' + +const Page = () => { + const pageTitle = 'SharePoint Templates' + const writePermissions = ['Sharepoint.Admin.ReadWrite'] + const { checkPermissions } = usePermissions() + const canWrite = checkPermissions(writePermissions) + + const actions = [ + { + label: 'Edit Template', + icon: , + color: 'warning', + link: '/teams-share/sharepoint-templates/add?template=[TemplateId]&name=[templateName]', + condition: () => canWrite, + }, + { + label: 'Copy Template', + icon: , + color: 'info', + link: '/teams-share/sharepoint-templates/add?template=[TemplateId]©=true&name=[templateName]', + condition: () => canWrite, + }, + { + label: 'Delete Template', + icon: , + color: 'danger', + type: 'POST', + url: '/api/ExecSharePointTemplate', + data: { + Action: 'Delete', + TemplateId: 'TemplateId', + }, + confirmText: 'Are you sure you want to delete [templateName]?', + condition: () => canWrite, + }, + ] + + const offCanvas = { + extendedInfoFields: [ + 'templateName', + 'SiteTemplateCount', + 'LibraryCount', + 'CreatedBy', + 'UpdatedBy', + 'Timestamp', + ], + actions: actions, + } + + return ( + + } + > + Create New Template + + + + } + /> + ) +} + +Page.getLayout = (page) => {page} + +export default Page diff --git a/src/pages/teams-share/sharepoint/add-site.js b/src/pages/teams-share/sharepoint/add-site.js index b6d2650d890b..bfc9de0e86a9 100644 --- a/src/pages/teams-share/sharepoint/add-site.js +++ b/src/pages/teams-share/sharepoint/add-site.js @@ -44,6 +44,9 @@ const AddSiteForm = () => { type="autoComplete" api={{ url: "/api/ListGraphRequest", + // CippAutocomplete appends the current tenant to this key, so switching + // tenants refetches the list. + queryKey: "AddSiteOwner", data: { Endpoint: "users", $filter: "accountEnabled eq true", diff --git a/src/pages/teams-share/sharepoint/index.js b/src/pages/teams-share/sharepoint/index.js index 42f08fdc0486..8994bc074e57 100644 --- a/src/pages/teams-share/sharepoint/index.js +++ b/src/pages/teams-share/sharepoint/index.js @@ -1,6 +1,6 @@ import { Layout as DashboardLayout } from '../../../layouts/index.js' import { CippTablePage } from '../../../components/CippComponents/CippTablePage.jsx' -import { Button } from '@mui/material' +import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material' import { Add, AddToPhotos, @@ -10,18 +10,135 @@ import { NoAccounts, Delete, CleaningServices, + Assessment, + FolderShared, + ManageAccounts, + PersonSearch, + 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' +import { CippLibraryPermissionsDialog } from '../../../components/CippComponents/CippLibraryPermissionsDialog' +import { CippCheckUserAccessDialog } from '../../../components/CippComponents/CippCheckUserAccessDialog' + +// Friendly labels for the SharePoint version cleanup (trim) job progress fields. +const VERSION_CLEANUP_LABELS = { + Status: 'Status', + BatchDeleteMode: 'Cleanup Mode', + RequestTimeInUTC: 'Requested (UTC)', + LastProcessTimeInUTC: 'Last Processed (UTC)', + CompleteTimeInUTC: 'Completed (UTC)', + ListsProcessed: 'Lists Processed', + ListsUpdated: 'Lists Updated', + ListsFailed: 'Lists Failed', + FilesProcessed: 'Files Processed', + VersionsProcessed: 'Versions Processed', + VersionsDeleted: 'Versions Deleted', + VersionsFailed: 'Versions Failed', + StorageReleased: 'Storage Released (bytes)', + ErrorMessage: 'Error Message', + WorkItemId: 'Work Item ID', +} +// Order in which the fields are shown. +const VERSION_CLEANUP_FIELDS = Object.keys(VERSION_CLEANUP_LABELS) + +// Renders the body of the status modal based on the fetched job progress. +const VersionCleanupStatusBody = ({ statusApi }) => { + const progress = statusApi.data?.Results + + if (statusApi.isError) { + return Failed to load cleanup job status. + } + + // No job: either an empty/blank response, or the API's explicit "NoRequestFound" status. + if ( + !statusApi.isFetching && + (progress === undefined || + progress === null || + (typeof progress === 'string' && progress.trim() === '') || + progress?.Status === 'NoRequestFound') + ) { + return No cleanup job found for this site. + } + + // Backend couldn't parse the payload and returned the raw string. + if (!statusApi.isFetching && typeof progress === 'string') { + return {progress} + } + + const propertyItems = VERSION_CLEANUP_FIELDS.filter( + (key) => progress?.[key] !== undefined && progress?.[key] !== '' + ).map((key) => ({ + label: VERSION_CLEANUP_LABELS[key], + value: String(progress[key]), + })) + + return ( + ({ label: VERSION_CLEANUP_LABELS[key], value: '' })) + } + /> + ) +} + +// Custom-component action modal: opens directly (no confirmation step) and fetches the trim +// job status for the selected site, rendering it as a property list. +const VersionCleanupStatusModal = ({ row, tenantFilter, drawerVisible, setDrawerVisible }) => { + const siteRow = Array.isArray(row) ? row[0] : row + const siteUrl = siteRow?.webUrl + const statusApi = ApiGetCall({ + url: '/api/ListSPOVersionCleanup', + data: { + tenantFilter: siteRow?.Tenant ?? tenantFilter, + SiteUrl: siteUrl, + }, + queryKey: `SPOVersionCleanupStatus-${siteUrl}`, + waiting: !!drawerVisible && !!siteUrl, + }) + + return ( + setDrawerVisible(false)}> + + Cleanup Job Status{siteRow?.displayName ? ` — ${siteRow.displayName}` : ''} + + + + + + + + + ) +} const Page = () => { const pageTitle = 'SharePoint Sites' 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', @@ -45,7 +162,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', @@ -71,7 +189,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, }, { @@ -82,38 +214,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', @@ -125,6 +414,7 @@ const Page = () => { URL: 'webUrl', }, confirmText: 'Select the User to add to the Site Admins permissions', + condition: () => canWriteSite, fields: [ { type: 'autoComplete', @@ -164,6 +454,7 @@ const Page = () => { URL: 'webUrl', }, confirmText: 'Select the User to remove from the Site Admins permissions', + condition: () => canWriteSite, fields: [ { type: 'autoComplete', @@ -192,6 +483,37 @@ const Page = () => { ], multiPost: false, }, + { + // Read access is enough to open this: the dialog gates every change on write access, + // so a read-only admin can still inspect who has what. + label: 'Manage Permissions', + icon: , + condition: () => canReadSite, + customComponent: (row, { drawerVisible, setDrawerVisible }) => ( + + ), + multiPost: false, + }, + { + // Answers "does this person have access, and how" rather than "who holds permissions". + label: 'Check User Access', + icon: , + condition: () => canReadSite, + customComponent: (row, { drawerVisible, setDrawerVisible }) => ( + + ), + multiPost: false, + }, { label: 'Delete Site', type: 'POST', @@ -201,8 +523,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, }, { @@ -215,6 +553,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 }) => ( <> { name="DeleteOlderThanDays" label="Delete Versions Older Than (days)" formControl={formHook} - validators={{ required: 'Please enter the number of days' }} + validators={{ + required: 'Please enter the number of days', + min: { value: 30, message: 'SharePoint requires at least 30 days' }, + }} /> { formControl={formHook} validators={{ required: 'Please enter the version limit' }} /> + ), defaultvalues: { BatchDeleteMode: '2', }, - customDataformatter: (row, action, formData) => ({ - tenantFilter: row.Tenant ?? tenantFilter, - SiteUrl: row.webUrl, - BatchDeleteMode: parseInt(formData.BatchDeleteMode, 10), - DeleteOlderThanDays: - formData.BatchDeleteMode === '0' ? parseInt(formData.DeleteOlderThanDays, 10) : -1, - MajorVersionLimit: - formData.BatchDeleteMode === '1' ? parseInt(formData.MajorVersionLimit, 10) : -1, - }), + customDataformatter: (row, action, formData) => { + const formatRow = (singleRow) => ({ + tenantFilter: singleRow.Tenant ?? tenantFilter, + SiteUrl: singleRow.webUrl, + BatchDeleteMode: parseInt(formData.BatchDeleteMode, 10), + DeleteOlderThanDays: + formData.BatchDeleteMode === '0' ? parseInt(formData.DeleteOlderThanDays, 10) : -1, + MajorVersionLimit: + formData.BatchDeleteMode === '1' ? parseInt(formData.MajorVersionLimit, 10) : -1, + MajorWithMinorVersionsLimit: + formData.BatchDeleteMode === '1' + ? parseInt(formData.MajorWithMinorVersionsLimit, 10) + : -1, + }) + // When multiple rows are selected, row is an array. Returning an array + // makes CippApiDialog send one request per row (bulk request mode). + return Array.isArray(row) ? row.map(formatRow) : formatRow(row) + }, + multiPost: false, + }, + { + label: 'Recycle Bin', + icon: , + condition: () => canReadRecycleBin, + customComponent: (row, { drawerVisible, setDrawerVisible }) => ( + + ), + multiPost: false, + }, + { + label: 'Check Cleanup Job Status', + icon: , + condition: () => canReadSite, + customComponent: (row, { drawerVisible, setDrawerVisible }) => ( + + ), multiPost: false, }, ] @@ -288,11 +674,12 @@ const Page = () => { 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..38958272b110 --- /dev/null +++ b/src/pages/teams-share/sharing-report/index.js @@ -0,0 +1,533 @@ +import { useRef, useState } from 'react' +import { Layout as DashboardLayout } from '../../../layouts/index.js' +import { CippInfoBar } from '../../../components/CippCards/CippInfoBar' +import { CippMultiQueueTracker } from '../../../components/CippComponents/CippMultiQueueTracker' +import { CippQueryRefreshButton } from '../../../components/CippComponents/CippQueryRefreshButton' +import { SharingReportButton } from '../../../components/CippPdf/SharingReportButton' +import { CippChartCard } from '../../../components/CippCards/CippChartCard' +import { CippImageCard } from '../../../components/CippCards/CippImageCard' +import { CippPropertyListCard } from '../../../components/CippCards/CippPropertyListCard' +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 { + CloudArrowDownIcon, + 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. Site and library +// permissions are a separate access path with their own cache and report page. +const syncRows = [ + { Name: 'SharePointSharingLinks' }, + { Name: 'SharePointSiteUsage' }, + { Name: 'OneDriveUsage' }, +] + +// Stable empty fallback: CippDataTable syncs its `data` prop by reference, so a fresh [] on every +// render would re-trigger that sync in a loop. +const EMPTY_ROWS = [] + +const Page = () => { + const currentTenant = useSettings().currentTenant + const syncDialog = useDialog() + // Each sync starts one queue per cache and returns its id in the response metadata, so the + // ids are collected as the four responses arrive and handed to the tracker. + const [syncQueueIds, setSyncQueueIds] = useState([]) + const newSyncRunRef = useRef(false) + 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 topLibraries = data.topLibraries ?? [] + const topRecipients = data.topRecipients ?? [] + 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', + }, + { + // Anyone with the link can change the content: the combination worth acting on first. + filterName: 'Anonymous + Editable', + value: [ + { id: 'classification', value: 'Anonymous' }, + { id: 'roles', value: 'write' }, + ], + type: 'column', + }, + { + // A share on a folder carries everything below it, so one link can expose a whole tree. + filterName: 'Folder Shares', + value: [{ id: 'itemType', value: 'Folder' }], + 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.UtcDateTime).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', + }, + ]} + /> + + + + + + + + + {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" + /> + + + item.library)} + chartSeries={topLibraries.map((item) => item.links)} + totalLabel="Links" + /> + + + item.recipient)} + chartSeries={topRecipients.map((item) => item.links)} + totalLabel="Shares" + /> + + + )} + + {(sharing.isFetching || summary.usageSynced) && ( + <> + + + + + + + + )} + + + + + + { + const queueId = result?.Metadata?.QueueId + if (!queueId) return + if (newSyncRunRef.current) { + newSyncRunRef.current = false + setSyncQueueIds([queueId]) + return + } + setSyncQueueIds((previous) => + previous.includes(queueId) ? previous : [...previous, queueId] + ) + }, + }} + row={syncRows} + /> + + )} + + + ) +} + +Page.getLayout = (page) => {page} + +export default Page diff --git a/src/pages/teams-share/teams/business-voice/index.js b/src/pages/teams-share/teams/business-voice/index.js index 3c9354706147..89f2d298ac76 100644 --- a/src/pages/teams-share/teams/business-voice/index.js +++ b/src/pages/teams-share/teams/business-voice/index.js @@ -35,7 +35,16 @@ const Page = () => { multiple: false, creatable: false, api: { - url: "/api/listUsers", + url: "/api/ListGraphRequest", + dataKey: "Results", + data: { + Endpoint: "users", + manualPagination: true, + $select: "id,userPrincipalName,displayName", + $count: true, + $orderby: "displayName", + $top: 999, + }, labelField: (input) => `${input.displayName} (${input.userPrincipalName})`, valueField: "userPrincipalName", }, diff --git a/src/pages/teams-share/teams/teams-activity/index.js b/src/pages/teams-share/teams/teams-activity/index.js index f5fb2bb53754..69d3fa3ebc72 100644 --- a/src/pages/teams-share/teams/teams-activity/index.js +++ b/src/pages/teams-share/teams/teams-activity/index.js @@ -1,6 +1,10 @@ import { Layout as DashboardLayout } from "../../../../layouts/index.js"; import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; import { useCippReportDB } from "../../../../components/CippComponents/CippReportDBControls"; +import { + CippAnonymizedReportAlert, + useReportAnonymized, +} from "../../../../components/CippComponents/CippAnonymizedReportAlert"; const Page = () => { const pageTitle = "Teams Activity List"; @@ -14,12 +18,19 @@ const Page = () => { defaultCached: false, }); + const anonymized = useReportAnonymized({ + url: reportDB.resolvedApiUrl, + queryKey: reportDB.resolvedQueryKey, + fields: ["UPN"], + }); + return ( <> } simpleColumns={[ ...reportDB.cacheColumns, "UPN", diff --git a/src/pages/tenant/administration/alert-configuration/alert.jsx b/src/pages/tenant/administration/alert-configuration/alert.jsx index 7829d9df639e..53b58dc39fbc 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' }, @@ -137,7 +165,11 @@ const AlertWizard = () => { if (alert?.LogType === 'Scripted') { setAlertType('script') const excludedTenantsFormatted = Array.isArray(alert.excludedTenants) - ? alert.excludedTenants.map((tenant) => ({ value: tenant, label: tenant })) + ? alert.excludedTenants.map((tenant) => + typeof tenant === 'object' && tenant !== null + ? tenant + : { value: tenant, label: tenant } + ) : [] const usedCommand = alertList?.find( (cmd) => cmd.name === alert.RawAlert.Command.replace('Get-CIPPAlert', '') @@ -203,6 +235,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 +251,7 @@ const AlertWizard = () => { startDateTime: startDateTimeForForm, CustomSubject: alert.RawAlert.CustomSubject || '', AlertComment: alert.RawAlert.AlertComment || '', + PsaTicketStrategy: psaStrategyValue, } if (usedCommand?.requiresInput && alert.RawAlert.Parameters) { try { @@ -519,6 +559,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 +653,17 @@ const AlertWizard = () => { }} /> - - - - - + + + @@ -928,23 +963,17 @@ const AlertWizard = () => { }} /> - - - - - + + + @@ -1095,6 +1124,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/applications/app-registrations.js b/src/pages/tenant/administration/applications/app-registrations.js index ee12b20ac12a..af618d46d7ff 100644 --- a/src/pages/tenant/administration/applications/app-registrations.js +++ b/src/pages/tenant/administration/applications/app-registrations.js @@ -1,4 +1,3 @@ -// this page is going to need some love for accounting for filters: https://github.com/KelvinTegelaar/CIPP/blob/main/src/views/tenant/administration/ListEnterpriseApps.jsx#L83 import { Layout as DashboardLayout } from '../../../../layouts/index.js' import { TabbedLayout } from '../../../../layouts/TabbedLayout' import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' diff --git a/src/pages/tenant/administration/applications/enterprise-apps.js b/src/pages/tenant/administration/applications/enterprise-apps.js index 445e694a72e1..cb99b7d6988a 100644 --- a/src/pages/tenant/administration/applications/enterprise-apps.js +++ b/src/pages/tenant/administration/applications/enterprise-apps.js @@ -1,4 +1,3 @@ -// this page is going to need some love for accounting for filters: https://github.com/KelvinTegelaar/CIPP/blob/main/src/views/tenant/administration/ListEnterpriseApps.jsx#L83 import { Layout as DashboardLayout } from '../../../../layouts/index.js' import { TabbedLayout } from '../../../../layouts/TabbedLayout' import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' @@ -48,7 +47,7 @@ const Page = () => { const apiParams = { Endpoint: 'servicePrincipals', $select: - 'id,appId,displayName,createdDateTime,accountEnabled,homepage,publisherName,signInAudience,replyUrls,verifiedPublisher,info,api,appOwnerOrganizationId,tags,passwordCredentials,keyCredentials', + 'id,appId,displayName,createdDateTime,accountEnabled,homepage,publisherName,signInAudience,replyUrls,verifiedPublisher,info,api,applicationTemplateId,appOwnerOrganizationId,tags,passwordCredentials,keyCredentials', $count: true, $top: 999, } 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..2961fb8e4467 100644 --- a/src/pages/tenant/administration/authentication-methods/index.js +++ b/src/pages/tenant/administration/authentication-methods/index.js @@ -1,17 +1,236 @@ import { Layout as DashboardLayout } from "../../../../layouts/index.js"; +import { TabbedLayout } from "../../../../layouts/TabbedLayout"; +import tabOptions from "./tabOptions.json"; 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"; const Page = () => { - const pageTitle = "Auth Methods"; + const pageTitle = "Policies"; const tenant = useSettings().currentTenant; const apiUrl = "/api/ListGraphRequest"; // 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 +265,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 +279,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 = { @@ -103,6 +359,10 @@ const Page = () => { }; // Adding the layout for the dashboard -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => ( + + {page} + +); export default Page; diff --git a/src/pages/tenant/administration/authentication-methods/registration-campaign.js b/src/pages/tenant/administration/authentication-methods/registration-campaign.js new file mode 100644 index 000000000000..2658cba5dbb3 --- /dev/null +++ b/src/pages/tenant/administration/authentication-methods/registration-campaign.js @@ -0,0 +1,252 @@ +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { Alert, Typography } from "@mui/material"; +import { Grid } from "@mui/system"; +import { Layout as DashboardLayout } from "../../../../layouts/index.js"; +import { TabbedLayout } from "../../../../layouts/TabbedLayout"; +import tabOptions from "./tabOptions.json"; +import CippFormPage from "../../../../components/CippFormPages/CippFormPage"; +import CippFormComponent from "../../../../components/CippComponents/CippFormComponent"; +import { ApiGetCall } from "../../../../api/ApiCall"; +import { useSettings } from "../../../../hooks/use-settings.js"; + +const stateOptions = [ + { label: "Microsoft managed", value: "default" }, + { label: "Enabled", value: "enabled" }, + { label: "Disabled", value: "disabled" }, +]; + +const methodOptions = [ + { label: "Microsoft Authenticator", value: "microsoftAuthenticator" }, + { label: "Passkey (FIDO2)", value: "fido2" }, +]; + +// Map campaign targets of one type to autocomplete options (all_users is handled by its own switch) +const targetsToOptions = (targets, targetType) => + (Array.isArray(targets) ? targets : []) + .filter((target) => target?.targetType === targetType && target?.id !== "all_users") + .map((target) => ({ label: target.id, value: target.id })); + +const toIdArray = (value) => + Array.isArray(value) ? value.map((item) => item.value).filter(Boolean) : []; + +const Page = () => { + const tenant = useSettings().currentTenant; + const queryKey = `RegistrationCampaign-${tenant}`; + + const formControl = useForm({ + mode: "onChange", + }); + + const campaignRequest = ApiGetCall({ + url: "/api/ListGraphRequest", + data: { + Endpoint: "authenticationMethodsPolicy", + tenantFilter: tenant, + }, + queryKey: queryKey, + }); + + const campaign = + campaignRequest.data?.Results?.[0]?.registrationEnforcement + ?.authenticationMethodsRegistrationCampaign; + + useEffect(() => { + if (campaignRequest.isSuccess && campaign) { + formControl.reset({ + state: stateOptions.find((option) => option.value === campaign.state) ?? stateOptions[0], + targetedAuthenticationMethod: + methodOptions.find( + (option) => option.value === campaign.includeTargets?.[0]?.targetedAuthenticationMethod, + ) ?? methodOptions[0], + snoozeDurationInDays: campaign.snoozeDurationInDays, + enforceRegistrationAfterAllowedSnoozes: !!campaign.enforceRegistrationAfterAllowedSnoozes, + includeAllUsers: (Array.isArray(campaign.includeTargets) + ? campaign.includeTargets + : [] + ).some((target) => target?.id === "all_users"), + includeGroups: targetsToOptions(campaign.includeTargets, "group"), + includeUsers: targetsToOptions(campaign.includeTargets, "user"), + excludeGroups: targetsToOptions(campaign.excludeTargets, "group"), + excludeUsers: targetsToOptions(campaign.excludeTargets, "user"), + }); + } + }, [campaignRequest.isSuccess, campaign]); + + const groupFieldApi = { + url: "/api/ListGraphRequest", + dataKey: "Results", + queryKey: `RegistrationCampaignGroups-${tenant}`, + labelField: (group) => (group.id ? `${group.displayName} (${group.id})` : group.displayName), + valueField: "id", + data: { + Endpoint: "groups", + manualPagination: true, + $select: "id,displayName", + $orderby: "displayName", + $top: 999, + $count: true, + }, + }; + + const userFieldApi = { + url: "/api/ListGraphRequest", + dataKey: "Results", + queryKey: `RegistrationCampaignUsers-${tenant}`, + labelField: (user) => `${user.displayName} (${user.userPrincipalName})`, + valueField: "id", + data: { + Endpoint: "users", + manualPagination: true, + $select: "id,displayName,userPrincipalName", + $orderby: "displayName", + $top: 999, + $count: true, + }, + }; + + return ( + ({ + tenantFilter: tenant, + state: values?.state?.value ?? values?.state, + targetedAuthenticationMethod: + values?.targetedAuthenticationMethod?.value ?? values?.targetedAuthenticationMethod, + snoozeDurationInDays: + values?.snoozeDurationInDays === "" || values?.snoozeDurationInDays === undefined + ? undefined + : Number(values?.snoozeDurationInDays), + enforceRegistrationAfterAllowedSnoozes: !!values?.enforceRegistrationAfterAllowedSnoozes, + includeAllUsers: !!values?.includeAllUsers, + includeGroups: toIdArray(values?.includeGroups), + includeUsers: toIdArray(values?.includeUsers), + excludeGroups: toIdArray(values?.excludeGroups), + excludeUsers: toIdArray(values?.excludeUsers), + })} + > + + + + Nudge users to set up Microsoft Authenticator or a passkey during sign-in. Users are + prompted after completing MFA and can snooze the prompt for the configured number of + days. + + + {campaignRequest.isError && ( + + + Failed to load the current registration campaign settings for this tenant. + + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +Page.getLayout = (page) => ( + + {page} + +); + +export default Page; diff --git a/src/pages/tenant/administration/authentication-methods/tabOptions.json b/src/pages/tenant/administration/authentication-methods/tabOptions.json new file mode 100644 index 000000000000..7133119254a2 --- /dev/null +++ b/src/pages/tenant/administration/authentication-methods/tabOptions.json @@ -0,0 +1,12 @@ +[ + { + "label": "Policies", + "path": "/tenant/administration/authentication-methods", + "icon": "Key" + }, + { + "label": "Registration Campaign", + "path": "/tenant/administration/authentication-methods/registration-campaign", + "icon": "Notifications" + } +] diff --git a/src/pages/tenant/administration/tenants/edit.js b/src/pages/tenant/administration/tenants/edit.js index 960e536859b1..cc6d59397910 100644 --- a/src/pages/tenant/administration/tenants/edit.js +++ b/src/pages/tenant/administration/tenants/edit.js @@ -69,16 +69,23 @@ const Page = () => { RemoveMFADevices: false, RemoveTeamsPhoneDID: false, ClearImmutableId: false, + DisableOneDriveSharing: false, + removeCalendarPermissions: false, + postExecution: { + psa: false, + email: false, + webhook: 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 +93,7 @@ const Page = () => { } else { offboardingDefaults = { offboardingDefaults: defaultOffboardingValues }; } - + offboardingFormControl.reset(offboardingDefaults); } }, [tenantDetails.isSuccess, tenantDetails.data, id]); @@ -113,8 +120,15 @@ const Page = () => { RemoveMFADevices: false, RemoveTeamsPhoneDID: false, ClearImmutableId: false, + DisableOneDriveSharing: false, + removeCalendarPermissions: false, + postExecution: { + psa: false, + email: false, + webhook: false, + }, }; - + offboardingFormControl.reset({ offboardingDefaults: defaultOffboardingValues }); }; @@ -222,7 +236,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/graph-office-reports/index.js b/src/pages/tenant/reports/graph-office-reports/index.js index 39bb153c06b6..c993de0ffcf0 100644 --- a/src/pages/tenant/reports/graph-office-reports/index.js +++ b/src/pages/tenant/reports/graph-office-reports/index.js @@ -8,6 +8,10 @@ import { Box, Container, Stack } from '@mui/system' import { CippHead } from '../../../../components/CippComponents/CippHead.jsx' import { CippDataTable } from '../../../../components/CippTable/CippDataTable.js' import CippFormComponent from '../../../../components/CippComponents/CippFormComponent' +import { + CippAnonymizedReportAlert, + isReportAnonymized, +} from '../../../../components/CippComponents/CippAnonymizedReportAlert' // Convert camelCase report names like "getMailboxUsageDetail" → "Mailbox Usage Detail" // Uses the same acronym-aware splitting as getCippTranslation @@ -147,6 +151,14 @@ const Page = () => { {currentTenant && !report && ( Select a report above to load data. )} + {currentTenant && report && ( + + )} {currentTenant && report && ( {reportDataApi.isError ? ( diff --git a/src/pages/tenant/reports/list-csp-licenses/index.jsx b/src/pages/tenant/reports/list-csp-licenses/index.jsx index 6cf2cacd9f44..c190b390c343 100644 --- a/src/pages/tenant/reports/list-csp-licenses/index.jsx +++ b/src/pages/tenant/reports/list-csp-licenses/index.jsx @@ -1,7 +1,7 @@ import { Layout as DashboardLayout } from "../../../../layouts/index.js"; import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; import { MinusIcon, PlusIcon } from "@heroicons/react/24/outline"; -import { DeleteForever, ShoppingCart } from "@mui/icons-material"; +import { DeleteForever, EventRepeat, ShoppingCart } from "@mui/icons-material"; import { Button } from "@mui/material"; import Link from "next/link"; @@ -62,6 +62,24 @@ const Page = () => { confirmText: "Enter the number of licences to remove. This must be a number greater than 0.", multiPost: false, }, + { + label: "Schedule decrease of 1 at next renewal", + type: "POST", + icon: , + url: "/api/ExecCSPLicense", + data: { Action: "!ScheduleRemoval", sku: "sku", Remove: 1 }, + fields: [ + { + type: "number", + name: "DaysBeforeRenewal", + label: "Days before renewal to execute (default 3)", + multiple: false, + }, + ], + confirmText: + "Schedule a decrease of 1 licence for [productName], executed shortly before the renewal date ([commitmentTerm.renewalConfiguration.renewalDate])? The decrease only happens if at least 1 licence is unassigned at that time; otherwise it is skipped and nothing changes.", + multiPost: false, + }, { label: "Cancel Subscription", type: "POST", 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/community-repos/index.js b/src/pages/tools/community-repos/index.js index 8b09c824d1e1..0fd6d4dce13d 100644 --- a/src/pages/tools/community-repos/index.js +++ b/src/pages/tools/community-repos/index.js @@ -304,7 +304,7 @@ const Page = () => { > setRepo(e.target.value)} required={true} diff --git a/src/pages/tools/custom-tests/add.jsx b/src/pages/tools/custom-tests/add.jsx index e4da31a623c5..ce8331a89b2b 100644 --- a/src/pages/tools/custom-tests/add.jsx +++ b/src/pages/tools/custom-tests/add.jsx @@ -1,5 +1,5 @@ import { Layout as DashboardLayout } from '../../../layouts/index.js' -import { Controller, useForm } from 'react-hook-form' +import { Controller, useForm, useFormState } from 'react-hook-form' import { ApiGetCall, ApiPostCall } from '../../../api/ApiCall' import { useRouter } from 'next/router' import { @@ -35,13 +35,35 @@ import { import cacheTypes from '../../../data/CIPPDBCacheTypes.json' import { renderCustomScriptMarkdownTemplate } from '../../../utils/customScriptTemplate' import { useSettings } from '../../../hooks/use-settings' -import CippFormPage from '../../../components/CippFormPages/CippFormPage' +import CippFormPage, { + useCippFormPageActions, +} from '../../../components/CippFormPages/CippFormPage' import CippFormComponent from '../../../components/CippComponents/CippFormComponent' import { CippFormCondition } from '../../../components/CippComponents/CippFormCondition' import { CippApiResults } from '../../../components/CippComponents/CippApiResults' import { CippCodeBlock } from '../../../components/CippComponents/CippCodeBlock' import { markdownStyles } from '../../../components/CippTestDetail/CippTestDetailOffCanvas' +// Renders CippFormPage's submit inside the tester panel so it sits next to Run Test +// instead of below the test results. +const CustomTestSaveButton = () => { + const formPage = useCippFormPageActions() + if (!formPage) { + return null + } + const { submit, isSubmitting, isValid, isDirty, allowResubmit } = formPage + return ( + + ) +} + const Page = () => { const getValueType = (value) => { if (value === null) { @@ -118,7 +140,7 @@ const Page = () => { ScriptContent: '', Enabled: false, AlertOnFailure: false, - AlertStatuses: [{ value: 'Failed', label: 'Failed' }], + AlertStatuses: { value: 'Failed', label: 'Failed' }, ReturnType: 'JSON', ResultMode: { value: 'Auto', label: 'Auto' }, MarkdownTemplate: '', @@ -133,6 +155,8 @@ const Page = () => { }, }) + const { isDirty } = useFormState({ control: formControl.control }) + const existingScript = ApiGetCall({ url: `/api/ListCustomScripts?ScriptGuid=${ScriptGuid}`, queryKey: `CustomScript-${ScriptGuid}`, @@ -148,12 +172,7 @@ const Page = () => { ScriptContent: script.ScriptContent || '', Enabled: script.Enabled || false, AlertOnFailure: script.AlertOnFailure || false, - AlertStatuses: script.AlertStatuses - ? (typeof script.AlertStatuses === 'string' - ? JSON.parse(script.AlertStatuses) - : script.AlertStatuses - ).map((s) => ({ value: s, label: s })) - : [{ value: 'Failed', label: 'Failed' }], + AlertStatuses: toSelectOption(script.AlertStatuses, 'Failed'), ReturnType: script.ReturnType || 'JSON', ResultMode: toSelectOption(script.ResultMode, 'Auto'), MarkdownTemplate: script.MarkdownTemplate || '', @@ -197,21 +216,51 @@ 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) => { setTestResults(result) if (result?.Results !== undefined) { const generatedSchema = buildResultSchema(result.Results) - formControl.setValue('ResultSchema', JSON.stringify(generatedSchema, null, 2), { - shouldDirty: true, - }) + // Compare entries only — generatedAt is a fresh timestamp on every run. Without this + // an unchanged re-run would dirty the form and disable Run Test until a no-op save. + let currentEntries = null + try { + currentEntries = JSON.parse(formControl.getValues('ResultSchema'))?.entries + } catch { + currentEntries = null + } + const schemaUnchanged = + !!currentEntries && + JSON.stringify(currentEntries) === JSON.stringify(generatedSchema.entries) + if (!schemaUnchanged) { + formControl.setValue('ResultSchema', JSON.stringify(generatedSchema, null, 2), { + shouldDirty: true, + }) + } } }, }) + // Run Test executes the *saved* script, so unsaved edits must be committed first. + const runTestDisabledReason = !isEdit + ? 'Save the script before running a test' + : isScriptLoading + ? 'Loading script...' + : isDirty + ? 'Save your changes before running a test' + : null + const handleRunTest = () => { - if (!isEdit || !ScriptGuid) { + if (!isEdit || !ScriptGuid || runTestDisabledReason) { return } @@ -252,7 +301,11 @@ const Page = () => { undefined, { shallow: false } ) + return } + // Rebaseline the dirty state so the saved values become the new "clean" form, + // which is what re-enables Run Test. + formControl.reset(formControl.getValues(), { keepValues: true }) } const customDataformatter = (data) => { @@ -261,9 +314,7 @@ const Page = () => { ScriptContent: data.ScriptContent, Enabled: data.Enabled, AlertOnFailure: data.AlertOnFailure, - AlertStatuses: data.AlertOnFailure - ? (data.AlertStatuses?.map(s => s.value) || ['Failed']) - : [], + AlertStatuses: data.AlertStatuses?.value ?? data.AlertStatuses, ReturnType: data.ReturnType, ResultMode: data.ResultMode?.value ?? data.ResultMode, MarkdownTemplate: data.MarkdownTemplate, @@ -325,6 +376,14 @@ const Page = () => { { value: 'AlwaysInvestigate', label: 'Always Investigate' }, ] + const AlertStatuses = [ + { value: 'Failed', label: 'Failed' }, + { value: 'Passed', label: 'Passed' }, + { value: 'Info', label: 'Info' }, + { value: 'Investigate', label: 'Investigate' }, + { value: 'All', label: 'All' }, + ] + const scriptNameField = { name: 'ScriptName', label: 'Script Name', @@ -415,14 +474,8 @@ const Page = () => { const alertStatusesField = { name: 'AlertStatuses', label: 'Alert on Status', - type: 'autoComplete', - multiple: true, - options: [ - { label: 'Failed', value: 'Failed' }, - { label: 'Passed', value: 'Passed' }, - { label: 'Info', value: 'Info' }, - { label: 'Investigate', value: 'Investigate' }, - ], + type: 'select', + options: AlertStatuses, helperText: 'Choose which test result statuses trigger an alert.', } @@ -675,6 +728,9 @@ All UPNs: {{join(Result[*].UserPrincipalName, ", ")}}`, postUrl="/api/AddCustomScript" customDataformatter={customDataformatter} onSubmitResult={handleSubmitResult} + // The tester panel owns Save whenever it is on screen, so results never push it + // to the bottom of the page. Fall back to the built-in footer button otherwise. + hideSubmit={testerExpanded} > - AST allowlist — approved cmdlets only. += is blocked. Data access - is automatically tenant-locked — do not pass{' '} - -TenantFilter. Type % in the editor for replacement - variables. + Runs in PowerShell ConstrainedLanguage — approved cmdlets + only. New-Object, {'[pscustomobject]@{}'} casts, and + .NET/reflection are blocked. Build rows with{' '} + {'Select-Object @{Name;Expression}'} and return a plain{' '} + {'@{}'} hashtable. Data access is tenant-locked — do not pass{' '} + -TenantFilter. Type % for replacement variables.
    @@ -907,20 +965,11 @@ $Licenses | ForEach-Object { # Build results - users with their resolved license names $results = $Users | Where-Object { $_.assignedLicenses.Count -gt 0 -} | ForEach-Object { - $user = $_ - $licenseNames = @($user.assignedLicenses | ForEach-Object { - $name = $SkuLookup[$_.skuId] - if ($name) { $name } else { $_.skuId } - }) - [PSCustomObject]@{ - UserPrincipalName = $user.userPrincipalName - DisplayName = $user.displayName - AccountEnabled = $user.accountEnabled - LicenseCount = $licenseNames.Count - Licenses = $licenseNames -join ', ' - } -} +} | Select-Object @{Name='UserPrincipalName'; Expression={ $_.userPrincipalName }}, + @{Name='DisplayName'; Expression={ $_.displayName }}, + @{Name='AccountEnabled'; Expression={ $_.accountEnabled }}, + @{Name='LicenseCount'; Expression={ @($_.assignedLicenses).Count }}, + @{Name='Licenses'; Expression={ (@($_.assignedLicenses | ForEach-Object { $n = $SkuLookup[$_.skuId]; if ($n) { $n } else { $_.skuId } }) -join ', ') }} # Build markdown table $header = "### Licensed Users: $($results.Count)\\n\\n| User | Display Name | Enabled | Licenses |\\n|---|---|---|---|" @@ -968,14 +1017,10 @@ $Users = Get-CIPPTestData -Type 'Users' $Users | Where-Object { $_.accountEnabled -eq $false -and $_.assignedLicenses.Count -gt 0 -} | ForEach-Object { - [PSCustomObject]@{ - UserPrincipalName = $_.userPrincipalName - DisplayName = $_.displayName - LicenseCount = $_.assignedLicenses.Count - Message = 'Disabled account with active license(s)' - } -}`} +} | Select-Object @{Name='UserPrincipalName'; Expression={ $_.userPrincipalName }}, + @{Name='DisplayName'; Expression={ $_.displayName }}, + @{Name='LicenseCount'; Expression={ @($_.assignedLicenses).Count }}, + @{Name='Message'; Expression={ 'Disabled account with active license(s)' }}`} language="powershell" showLineNumbers={true} /> @@ -1008,14 +1053,10 @@ $RegDetails = Get-CIPPTestData -Type 'UserRegistrationDetails' $noMfa = $RegDetails | Where-Object { $_.methodsRegistered.Count -eq 0 -and $_.userType -ne 'guest' -} | ForEach-Object { - [PSCustomObject]@{ - UserPrincipalName = $_.userPrincipalName - UserDisplayName = $_.userDisplayName - IsAdmin = $_.isAdmin - Message = 'No MFA methods registered' - } -} +} | Select-Object @{Name='UserPrincipalName'; Expression={ $_.userPrincipalName }}, + @{Name='UserDisplayName'; Expression={ $_.userDisplayName }}, + @{Name='IsAdmin'; Expression={ $_.isAdmin }}, + @{Name='Message'; Expression={ 'No MFA methods registered' }} $count = @($noMfa).Count if ($count -gt 0) { @@ -1068,18 +1109,11 @@ $cutoff = (Get-Date).AddDays(-$DaysThreshold) $Guests | Where-Object { -not $_.signInActivity.lastSignInDateTime -or [datetime]$_.signInActivity.lastSignInDateTime -lt $cutoff -} | ForEach-Object { - $lastSign = if ($_.signInActivity.lastSignInDateTime) { - $_.signInActivity.lastSignInDateTime - } else { 'Never' } - [PSCustomObject]@{ - UserPrincipalName = $_.userPrincipalName - DisplayName = $_.displayName - CreatedDateTime = $_.createdDateTime - LastSignIn = $lastSign - Message = "No sign-in within $DaysThreshold days" - } -}`} +} | Select-Object @{Name='UserPrincipalName'; Expression={ $_.userPrincipalName }}, + @{Name='DisplayName'; Expression={ $_.displayName }}, + @{Name='CreatedDateTime'; Expression={ $_.createdDateTime }}, + @{Name='LastSignIn'; Expression={ if ($_.signInActivity.lastSignInDateTime) { $_.signInActivity.lastSignInDateTime } else { 'Never' } }}, + @{Name='Message'; Expression={ "No sign-in within $DaysThreshold days" }}`} language="powershell" showLineNumbers={true} /> @@ -1111,12 +1145,8 @@ $Guests | Where-Object { $Policies = Get-CIPPTestData -Type 'ConditionalAccessPolicies' $grouped = $Policies | Group-Object -Property state -$counts = $grouped | ForEach-Object { - [PSCustomObject]@{ - State = $_.Name - Count = $_.Count - } -} +$counts = $grouped | Select-Object @{Name='State'; Expression={ $_.Name }}, + @{Name='Count'; Expression={ $_.Count }} # Build markdown summary — %tenantname% is replaced at runtime $header = "### %tenantname% — CA Policies: $(@($Policies).Count) total\n\n| State | Count |\n|---|---|" @@ -1201,9 +1231,9 @@ $md = $summaryTable + "\n\n---\n\n" + $policyTable Loading sample data... - ) : cacheExplorerApi.data?.Results?.length > 0 ? ( + ) : hasCacheSample ? ( @@ -1323,6 +1353,7 @@ $md = $summaryTable + "\n\n---\n\n" + $policyTable formControl={formControl} compareType="is" compareValue={true} + clearOnHide={false} > % to insert replacement variables (e.g.{' '} %tenantid%, %defaultdomain%, or custom variables). + + Scripts run in ConstrainedLanguage. Build output rows with{' '} + {'Select-Object @{Name;Expression}'} (not{' '} + {'[pscustomobject]@{}'}) and return a{' '} + {'@{ CIPPStatus = ... }'} hashtable. New-Object and + .NET reflection are blocked. + {hasTenantFilterParam && ( -TenantFilter is not needed — data access functions are @@ -1460,7 +1498,10 @@ $md = $summaryTable + "\n\n---\n\n" + $policyTable {testerExpanded && (!isEdit ? ( - Save the script first to test execution output. + + Save the script first to test execution output. + + ) : ( @@ -1480,15 +1521,28 @@ $md = $summaryTable + "\n\n---\n\n" + $policyTable "ExcludeDisabled": true }`} /> - - - + + + + + + {isDirty && ( + + Unsaved changes — tests run the saved version of the script. + + )} + {testScriptApi.isPending && ( diff --git a/src/pages/tools/report-builder/builder/index.js b/src/pages/tools/report-builder/builder/index.js index fa9df1b354fa..62047c7b0a2b 100644 --- a/src/pages/tools/report-builder/builder/index.js +++ b/src/pages/tools/report-builder/builder/index.js @@ -876,18 +876,20 @@ const Page = () => { if (desc) parts.push(desc) } + // Mirror CippTestDetailOffCanvas: a script that emitted its own markdown wins over + // the template renderer, whose empty-template fallback dumps raw JSON. let resultContent = '' - if (result.TestType === 'Custom' && result.ResultDataJson) { + if (result.ResultMarkdown) { + resultContent = result.ResultMarkdown + } else if (result.TestType === 'Custom' && result.ResultDataJson) { try { resultContent = renderCustomScriptMarkdownTemplate( JSON.parse(result.ResultDataJson), result.MarkdownTemplate || '' ) } catch { - resultContent = result.ResultMarkdown || '' + resultContent = '' } - } else { - resultContent = result.ResultMarkdown || '' } resultContent = maybeStripRemediation(resultContent) @@ -1323,6 +1325,7 @@ const Page = () => { label="Block Type" formControl={addBlockForm} multiple={false} + creatable={false} options={[ { label: 'Custom Block', value: 'blank' }, { label: 'Test Result', value: 'test' }, @@ -1343,6 +1346,7 @@ const Page = () => { name="testSuite" label="Test Suite" formControl={addBlockForm} + creatable={false} multiple={false} options={suiteOptions} isFetching={availableTestsApi.isFetching} @@ -1354,6 +1358,7 @@ const Page = () => { name="selectedTest" label="Select Tests" formControl={addBlockForm} + creatable={false} multiple={true} options={filteredTestOptions} isFetching={availableTestsApi.isFetching} @@ -1374,6 +1379,7 @@ const Page = () => { name="dbCacheType" label="Data Source" formControl={addBlockForm} + creatable={false} multiple={false} options={availableCacheTypes} isFetching={availableCacheTypesApi.isFetching} @@ -1385,6 +1391,7 @@ const Page = () => { name="dbFormat" label="Format" formControl={addBlockForm} + creatable={false} multiple={false} options={[ { label: 'Table (Text)', value: 'text' }, 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 3c6b6a066031..c31b0eefc8ff 100644 --- a/src/utils/get-cipp-formatting.js +++ b/src/utils/get-cipp-formatting.js @@ -35,6 +35,7 @@ import { getSignInErrorCodeTranslation } from './get-cipp-signin-errorcode-trans import { CollapsibleChipList } from '../components/CippComponents/CollapsibleChipList' import countryList from '../data/countryList.json' import { getStandards } from './standards-data' +import { parseCippDate } from './parse-cipp-date' // Helper function to convert country codes to country names const getCountryNameFromCode = (countryCode) => { @@ -138,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] @@ -180,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', @@ -217,9 +292,9 @@ export const getCippFormatting = (data, cellName, type, canReceive, flatten = tr const matchDateTime = /([dD]ate[tT]ime|[Ee]xpiration|[Tt]imestamp|[sS]tart[Dd]ate)/ if (timeAgoArray.includes(cellName) || matchDateTime.test(cellName)) { return isText && canReceive === false ? ( - new Date(data).toLocaleString() // This runs if canReceive is false and isText is true + parseCippDate(data).toLocaleString() // This runs if canReceive is false and isText is true ) : isText && canReceive !== 'both' ? ( - new Date(data) // This runs if isText is true and canReceive is not "both" or false + parseCippDate(data) // This runs if isText is true and canReceive is not "both" or false ) : ( ) @@ -1036,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/get-cipp-tenant-group-options.js b/src/utils/get-cipp-tenant-group-options.js index 97a7fe9a8981..b3edb805e45f 100644 --- a/src/utils/get-cipp-tenant-group-options.js +++ b/src/utils/get-cipp-tenant-group-options.js @@ -134,6 +134,11 @@ export const getTenantGroupPropertyOptions = () => { value: "customVariable", type: "customVariable", }, + { + label: "GDAP Relationship Age (days)", + value: "gdapRelationshipAge", + type: "gdapAge", + }, ]; }; @@ -175,11 +180,35 @@ export const getTenantGroupOperatorOptions = (propertyType) => { }, ]; + const numericOperators = [ + { + label: "Greater Than", + value: "gt", + }, + { + label: "Greater Than or Equal", + value: "ge", + }, + { + label: "Less Than", + value: "lt", + }, + { + label: "Less Than or Equal", + value: "le", + }, + ]; + // Custom Variable supports text comparison if (propertyType === "customVariable") { return [...baseOperators, ...textOperators]; } + // GDAP relationship age is numeric + if (propertyType === "gdapAge") { + return [...numericOperators, ...baseOperators]; + } + // Delegated Access Status only supports equals/not equals if (propertyType === "delegatedAccess") { return baseOperators; @@ -213,6 +242,9 @@ export const getTenantGroupValueOptions = (propertyType) => { case "customVariable": // Return empty array - uses free-text input with variable name return []; + case "gdapAge": + // Return empty array - uses a plain number input + return []; default: return []; } 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/parse-cipp-date.js b/src/utils/parse-cipp-date.js new file mode 100644 index 000000000000..f9ed14f41d4e --- /dev/null +++ b/src/utils/parse-cipp-date.js @@ -0,0 +1,16 @@ +// Parse a date value coming from the CIPP API into a JS Date. +// +// Some backend tables (e.g. ScheduledTasks / Extension Sync) store timestamps as a +// Unix epoch in *seconds*, often cast to a string (e.g. "1719225600"). Passing that +// straight to `new Date()` yields an Invalid Date, which silently breaks table +// sorting and date-range filtering. Numeric / all-digit values are therefore treated +// as epoch seconds and multiplied by 1000; everything else (ISO 8601 strings, etc.) +// is passed through to the native Date parser. +const allDigits = /^\d+$/ + +export const parseCippDate = (data) => { + if (typeof data === 'number' || (typeof data === 'string' && allDigits.test(data))) { + return new Date(Number(data) * 1000) + } + return new Date(data) +} 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..78a7510ae7d5 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" @@ -2993,10 +2960,10 @@ axe-core@^4.10.0: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.11.1.tgz#052ff9b2cbf543f5595028b583e4763b40c78ea7" integrity sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A== -axios@1.16.1: - version "1.16.1" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.16.1.tgz#517e29291d19d6e8cf919ff264f4fe157261ba12" - integrity sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A== +axios@1.18.1: + version "1.18.1" + resolved "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz#d63f9863bcd8938815c86f9e2abd380189d96dfe" + integrity sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g== dependencies: follow-redirects "^1.16.0" form-data "^4.0.5" @@ -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" @@ -3674,10 +3636,10 @@ dfa@^1.2.0: resolved "https://registry.yarnpkg.com/dfa/-/dfa-1.2.0.tgz#96ac3204e2d29c49ea5b57af8d92c2ae12790657" integrity sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q== -diff@^8.0.3: - version "8.0.4" - resolved "https://registry.yarnpkg.com/diff/-/diff-8.0.4.tgz#4f5baf3188b9b2431117b962eb20ba330fadf696" - integrity sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw== +diff@^9.0.0: + version "9.0.0" + resolved "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz#297c31cd7c280f13dfe335791ec2063bd4a73a6f" + integrity sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw== doctrine@^2.1.0: version "2.1.0" @@ -3742,10 +3704,10 @@ dompurify@3.2.7: optionalDependencies: "@types/trusted-types" "^2.0.7" -dompurify@^3.3.1, dompurify@^3.4.9: - version "3.4.9" - resolved "https://registry.npmjs.org/dompurify/-/dompurify-3.4.9.tgz#b036637b2df8997126b58837bc90f85dbcad6b2f" - integrity sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ== +dompurify@^3.3.1, dompurify@^3.4.12: + version "3.4.12" + resolved "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz#6fa2265e9bbdce882c4ace4107626051b448ffa8" + integrity sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg== optionalDependencies: "@types/trusted-types" "^2.0.7" @@ -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: @@ -6325,10 +6263,10 @@ prelude-ls@^1.2.1: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== -prettier@^3.8.1: - version "3.8.1" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173" - integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg== +prettier@^3.9.5: + version "3.9.5" + resolved "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz#4fec97736e33b9d0b620b48914fe93b530e835ad" + integrity sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg== prismjs@^1.30.0: version "1.30.0" @@ -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,10 +7551,10 @@ 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== +typescript@5.9.3: + version "5.9.3" + resolved "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== unbox-primitive@^1.1.0: version "1.1.0"