diff --git a/.github/agents/CIPP-Alert-Agent.md b/.github/agents/CIPP-Alert-Agent.md new file mode 100644 index 000000000000..6b29ec142fed --- /dev/null +++ b/.github/agents/CIPP-Alert-Agent.md @@ -0,0 +1,45 @@ +--- +name: CIPP Frontend Alert Registrar +description: > + Adds new alert entries to src/data/alerts.json in the CIPP frontend. + The agent must never modify any other file or perform any other change. +--- + +# CIPP Frontend Alert Registrar + +## Mission + +You are a **frontend alert registrar** responsible for updating the `src/data/alerts.json` file to include new alerts. + +Your role is **strictly limited** to adding a new JSON entry describing the alert’s metadata. +You do not touch or inspect any other part of the codebase. + +--- + +## Scope of Work + +This agent is used when a new alert must be surfaced to the frontend — for example, after a new backend `Get-CIPPAlert*.ps1` alert has been added. + +Tasks include: + +- Opening `src/data/alerts.json` +- Appending one new JSON object describing the new alert +- Preserving JSON structure, indentation, and trailing commas exactly as in the existing file +- Validating that the resulting JSON is syntactically correct + + +## Alert Format + +Each alert entry in `src/data/alerts.json` is a JSON object with the following structure: + +```json +{ + "name": "", + "label": "A nice label for the alert", + "requiresInput": true, + "inputType": "switch", + "inputLabel": "Exclude disabled users?", + "inputName": "InactiveLicensedUsersExcludeDisabled", + "recommendedRunInterval": "1d" +} +``` diff --git a/.github/agents/CIPP-Frontend-Alert-Agent.md b/.github/agents/CIPP-Frontend-Alert-Agent.md new file mode 100644 index 000000000000..6b29ec142fed --- /dev/null +++ b/.github/agents/CIPP-Frontend-Alert-Agent.md @@ -0,0 +1,45 @@ +--- +name: CIPP Frontend Alert Registrar +description: > + Adds new alert entries to src/data/alerts.json in the CIPP frontend. + The agent must never modify any other file or perform any other change. +--- + +# CIPP Frontend Alert Registrar + +## Mission + +You are a **frontend alert registrar** responsible for updating the `src/data/alerts.json` file to include new alerts. + +Your role is **strictly limited** to adding a new JSON entry describing the alert’s metadata. +You do not touch or inspect any other part of the codebase. + +--- + +## Scope of Work + +This agent is used when a new alert must be surfaced to the frontend — for example, after a new backend `Get-CIPPAlert*.ps1` alert has been added. + +Tasks include: + +- Opening `src/data/alerts.json` +- Appending one new JSON object describing the new alert +- Preserving JSON structure, indentation, and trailing commas exactly as in the existing file +- Validating that the resulting JSON is syntactically correct + + +## Alert Format + +Each alert entry in `src/data/alerts.json` is a JSON object with the following structure: + +```json +{ + "name": "", + "label": "A nice label for the alert", + "requiresInput": true, + "inputType": "switch", + "inputLabel": "Exclude disabled users?", + "inputName": "InactiveLicensedUsersExcludeDisabled", + "recommendedRunInterval": "1d" +} +``` diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000000..d6c9564cdd27 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "npm" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" + target-branch: "dev" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + target-branch: "dev" diff --git a/.github/workflows/Assign_Issue_Volunteer.yml b/.github/workflows/Assign_Issue_Volunteer.yml index 08f1e82701d0..23ef1d16ffc0 100644 --- a/.github/workflows/Assign_Issue_Volunteer.yml +++ b/.github/workflows/Assign_Issue_Volunteer.yml @@ -1,10 +1,10 @@ --- -name: 'Assign Issue to Volunteer' -on: [issue_comment] # yamllint disable-line rule:truthy +name: "Assign Issue to Volunteer" +on: [issue_comment] # yamllint disable-line rule:truthy jobs: build: - runs-on: ubuntu-latest + runs-on: ubuntu-slim steps: - uses: bhermann/issue-volunteer@v0.1.12 with: - GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/Check_for_Version_Update.yml b/.github/workflows/Check_for_Version_Update.yml index f8a7db0343f2..001dfdb4ffa5 100644 --- a/.github/workflows/Check_for_Version_Update.yml +++ b/.github/workflows/Check_for_Version_Update.yml @@ -8,7 +8,7 @@ jobs: build: if: github.repository_owner == 'KelvinTegelaar' name: "Check for Version Update" - runs-on: ubuntu-latest + runs-on: ubuntu-slim steps: - name: Check for Changed Files uses: brettcannon/check-for-changed-files@v1.1.0 diff --git a/.github/workflows/Close_Stale_Issues_and_PRs.yml b/.github/workflows/Close_Stale_Issues.yml similarity index 51% rename from .github/workflows/Close_Stale_Issues_and_PRs.yml rename to .github/workflows/Close_Stale_Issues.yml index 88607a42675d..f061a5a5a7ff 100644 --- a/.github/workflows/Close_Stale_Issues_and_PRs.yml +++ b/.github/workflows/Close_Stale_Issues.yml @@ -1,18 +1,20 @@ --- -name: 'Close stale issues and PRs' +name: "Close stale issues" on: schedule: - - cron: '30 1 * * *' + - cron: "30 1 * * *" jobs: stale: if: github.repository_owner == 'KelvinTegelaar' - runs-on: ubuntu-latest + runs-on: ubuntu-slim steps: - uses: actions/stale@v4 with: - stale-issue-message: 'This issue is stale because it has been open 10 days with no activity. We will close this issue soon. If you want this feature implemented you can contribute it. See: https://docs.cipp.app/dev-documentation/contributing-to-the-code . Please notify the team if you are working on this yourself.' - close-issue-message: 'This issue was closed because it has been stalled for 14 days with no activity.' - stale-issue-label: 'no-activity' - exempt-issue-labels: 'planned,bug,roadmap' + stale-issue-message: "This issue is stale because it has been open 10 days with no activity. We will close this issue soon. If you want this feature implemented you can contribute it. See: https://docs.cipp.app/dev-documentation/contributing-to-the-code . Please notify the team if you are working on this yourself." + close-issue-message: "This issue was closed because it has been stalled for 14 days with no activity." + stale-issue-label: "no-activity" + exempt-issue-labels: "planned,bug,roadmap" days-before-stale: 9 days-before-close: 5 + days-before-pr-stale: -1 + days-before-pr-close: -1 diff --git a/.github/workflows/CodeQL_Analyser.yml b/.github/workflows/CodeQL_Analyser.yml index 043426a1d69f..11aa414aaf79 100644 --- a/.github/workflows/CodeQL_Analyser.yml +++ b/.github/workflows/CodeQL_Analyser.yml @@ -1,10 +1,10 @@ --- -name: 'CodeQL' +name: "CodeQL" on: pull_request: branches: [master, main, dev, react] schedule: - - cron: '26 17 * * 0' + - cron: "26 17 * * 0" jobs: analyze: if: github.repository_owner == 'KelvinTegelaar' @@ -17,15 +17,15 @@ jobs: strategy: fail-fast: false matrix: - language: ['javascript'] + language: ["javascript"] steps: - name: Checkout Repository - uses: actions/checkout@v2 + uses: actions/checkout@v6 - name: Initialize CodeQL - uses: github/codeql-action/init@v1 + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} - name: Autobuild - uses: github/codeql-action/autobuild@v1 + uses: github/codeql-action/autobuild@v4 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/Comment_on_Issues.yml b/.github/workflows/Comment_on_Issues.yml index 8d83f6237d82..c408d8b38c84 100644 --- a/.github/workflows/Comment_on_Issues.yml +++ b/.github/workflows/Comment_on_Issues.yml @@ -7,7 +7,7 @@ on: jobs: add-comment_bug: if: github.repository_owner == 'KelvinTegelaar' && github.event.label.name == 'unconfirmed-by-user' - runs-on: ubuntu-latest + runs-on: ubuntu-slim permissions: issues: write steps: @@ -18,5 +18,5 @@ jobs: body: | Thank you for reporting a potential bug. If you would like to work on this bug, please comment: > I would like to work on this please! - + Thank you for helping us maintain the project! diff --git a/.github/workflows/Detect_Duplicate_Issues.yml b/.github/workflows/Detect_Duplicate_Issues.yml new file mode 100644 index 000000000000..e6f8e1d8fbd8 --- /dev/null +++ b/.github/workflows/Detect_Duplicate_Issues.yml @@ -0,0 +1,24 @@ +--- +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/Label_Issues.yml b/.github/workflows/Label_Issues.yml index 38865a62fae8..ebdc3992980f 100644 --- a/.github/workflows/Label_Issues.yml +++ b/.github/workflows/Label_Issues.yml @@ -7,23 +7,23 @@ on: jobs: label_issues_bugs: if: github.repository_owner == 'KelvinTegelaar' && contains(github.event.issue.title, 'Bug') - runs-on: ubuntu-latest + runs-on: ubuntu-slim permissions: issues: write steps: - name: Label Issues uses: andymckay/labeler@5c59dabdfd4dd5bd9c6e6d255b01b9d764af4414 with: - add-labels: 'not-assigned' + add-labels: "not-assigned" repo-token: ${{ secrets.GITHUB_TOKEN }} label_issues_frs: if: github.repository_owner == 'KelvinTegelaar' && contains(github.event.issue.title, 'Feature') - runs-on: ubuntu-latest + runs-on: ubuntu-slim permissions: issues: write steps: - name: Label Issues uses: andymckay/labeler@5c59dabdfd4dd5bd9c6e6d255b01b9d764af4414 with: - add-labels: 'enhancement, not-assigned' + add-labels: "enhancement, not-assigned" repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/Node_Project_Check.yml b/.github/workflows/Node_Project_Check.yml index b135c589ea7b..0a777ff925da 100644 --- a/.github/workflows/Node_Project_Check.yml +++ b/.github/workflows/Node_Project_Check.yml @@ -15,12 +15,12 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [16.x] + node-version: ["22.13.0"] os: [ubuntu-latest] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v2 + uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} - name: Install and Build Test diff --git a/.github/workflows/auto_comments.yml b/.github/workflows/auto_comments.yml index 7d7b11b45474..6cd003a36ae8 100644 --- a/.github/workflows/auto_comments.yml +++ b/.github/workflows/auto_comments.yml @@ -7,7 +7,7 @@ on: jobs: handle_comment: - runs-on: ubuntu-latest + runs-on: ubuntu-slim # We need permissions to modify issue comments. # 'issues: write' is required for deleting comments. permissions: diff --git a/.github/workflows/label_sponsor_requests.yml b/.github/workflows/label_sponsor_requests.yml index 479cad06c728..bb9d6a31b427 100644 --- a/.github/workflows/label_sponsor_requests.yml +++ b/.github/workflows/label_sponsor_requests.yml @@ -1,4 +1,3 @@ ---- name: Label Issues on: issues: @@ -7,11 +6,13 @@ on: jobs: label_issues_bugs: if: github.repository_owner == 'KelvinTegelaar' && contains(github.event.issue.title, 'Bug') - runs-on: ubuntu-latest + runs-on: ubuntu-slim permissions: issues: write steps: - name: Sponsor Labels uses: JasonEtco/is-sponsor-label-action@v1.2.0 with: - label: 'Sponsor Request' + label: "Sponsor Priority" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pr_check.yml b/.github/workflows/pr_check.yml index 0d9e2fd7abcd..08cce1de130b 100644 --- a/.github/workflows/pr_check.yml +++ b/.github/workflows/pr_check.yml @@ -16,7 +16,7 @@ permissions: jobs: check-branch: - runs-on: ubuntu-latest + runs-on: ubuntu-slim steps: - name: Check and Comment on PR # Only process fork PRs with specific branch conditions @@ -46,14 +46,14 @@ jobs: } message += '🔒 This PR will now be automatically closed due to the above rules.'; - + // Post the comment await github.rest.issues.createComment({ ...context.repo, issue_number: context.issue.number, body: message }); - + // Close the PR await github.rest.pulls.update({ ...context.repo, diff --git a/.gitignore b/.gitignore index 97735ad0415c..78ea4526e7ce 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules .pnp .pnp.js build +package-lock.json # testing coverage diff --git a/cspell.json b/cspell.json index 9ffb9e0d6e27..00a5c1785b77 100644 --- a/cspell.json +++ b/cspell.json @@ -14,8 +14,10 @@ "cipp", "CIPP", "CIPP-API", + "CISA", "Datto", "DMARC", + "EIDSCA", "Entra", "ESET", "GDAP", diff --git a/generate-placeholders.js b/generate-placeholders.js index 2b34888fca7a..304e6402e4ed 100644 --- a/generate-placeholders.js +++ b/generate-placeholders.js @@ -43,7 +43,7 @@ const pages = [ { title: "BPA Report Builder", path: "/tenant/tools/bpa-report-builder" }, { title: "Standards", path: "/tenant/standards" }, { title: "Edit Standards", path: "/tenant/standards/list-applied-standards" }, - { title: "List Standards", path: "/tenant/standards/list-standards" }, + { title: "List Standards", path: "/tenant/standards" }, { title: "Best Practice Analyser", path: "/tenant/standards/bpa-report" }, { title: "Domains Analyser", path: "/tenant/standards/domains-analyser" }, { title: "Conditional Access", path: "/tenant/administration" }, diff --git a/next.config.js b/next.config.js index 7a97a124b4bb..416f3f22d1df 100644 --- a/next.config.js +++ b/next.config.js @@ -4,12 +4,13 @@ const config = { images: { unoptimized: true, }, - webpack(config) { - config.module.rules.push({ - test: /\.svg$/, - use: ["@svgr/webpack"], - }); - return config; + turbopack: { + rules: { + "*.svg": { + loaders: ["@svgr/webpack"], + as: "*.js", + }, + }, }, async redirects() { return []; diff --git a/package.json b/package.json index c605096bc91b..059e1aabea30 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cipp", - "version": "8.7.0", + "version": "10.0.9", "author": "CIPP Contributors", "homepage": "https://cipp.app/", "bugs": { @@ -30,14 +30,16 @@ "@emotion/styled": "11.14.1", "@heroicons/react": "2.2.0", "@monaco-editor/react": "^4.6.0", - "@mui/icons-material": "7.3.2", + "@mui/icons-material": "7.3.7", "@mui/lab": "7.0.0-beta.17", - "@mui/material": "7.3.2", + "@mui/material": "7.3.7", "@mui/system": "7.3.2", - "@mui/x-date-pickers": "^8.11.1", + "@mui/x-date-pickers": "^8.25.0", "@musement/iso-duration": "^1.0.0", + "@nivo/core": "^0.99.0", + "@nivo/sankey": "^0.99.0", "@react-pdf/renderer": "^4.3.0", - "@reduxjs/toolkit": "2.9.0", + "@reduxjs/toolkit": "^2.11.2", "@tanstack/query-sync-storage-persister": "^5.76.0", "@tanstack/react-query": "^5.51.11", "@tanstack/react-query-devtools": "^5.51.11", @@ -51,6 +53,7 @@ "@tiptap/react": "^3.4.1", "@tiptap/starter-kit": "^3.4.1", "@uiw/react-json-view": "^2.0.0-alpha.30", + "@vvo/tzdb": "^6.198.0", "apexcharts": "5.3.5", "axios": "^1.7.2", "date-fns": "4.1.0", @@ -60,7 +63,7 @@ "gray-matter": "4.0.3", "i18next": "25.5.2", "javascript-time-ago": "^2.5.11", - "jspdf": "^3.0.0", + "jspdf": "^4.1.0", "jspdf-autotable": "^5.0.2", "leaflet": "^1.9.4", "leaflet-defaulticon-compatibility": "^0.1.2", @@ -69,18 +72,18 @@ "material-react-table": "^3.0.1", "monaco-editor": "^0.53.0", "mui-tiptap": "^1.14.0", - "next": "^15.2.2", + "next": "^16.1.2", "nprogress": "0.2.0", "numeral": "2.0.6", "prop-types": "15.8.1", "punycode": "^2.3.1", - "react": "19.1.1", + "react": "19.2.3", "react-apexcharts": "1.7.0", "react-beautiful-dnd": "13.1.1", "react-copy-to-clipboard": "^5.1.0", - "react-dom": "19.1.1", + "react-dom": "19.2.3", "react-dropzone": "14.3.8", - "react-error-boundary": "^6.0.0", + "react-error-boundary": "^6.1.0", "react-grid-layout": "^1.5.0", "react-hook-form": "^7.53.0", "react-hot-toast": "2.6.0", @@ -93,25 +96,26 @@ "react-papaparse": "^4.4.0", "react-quill": "^2.0.0", "react-redux": "9.2.0", - "react-syntax-highlighter": "^15.6.1", + "react-syntax-highlighter": "^16.1.0", "react-time-ago": "^7.3.3", "react-virtuoso": "^4.12.8", - "react-window": "^2.1.0", + "react-window": "^2.2.5", + "recharts": "^3.6.0", "redux": "5.0.1", "redux-devtools-extension": "2.13.9", "redux-persist": "^6.0.0", "redux-thunk": "3.1.0", "rehype-raw": "^7.0.0", - "remark-gfm": "^3.0.1", + "remark-gfm": "^4.0.0", "simplebar": "6.3.2", "simplebar-react": "3.3.2", "stylis-plugin-rtl": "2.1.1", "typescript": "5.9.2", - "yup": "1.7.0" + "yup": "1.7.1" }, "devDependencies": { "@svgr/webpack": "8.1.0", - "eslint": "9.35.0", + "eslint": "9.39.2", "eslint-config-next": "15.5.2" } } diff --git a/public/secureScore.json b/public/secureScore.json index e5fbf11c7b98..3e59bbd95d5d 100644 --- a/public/secureScore.json +++ b/public/secureScore.json @@ -49,7 +49,7 @@ "vendor": "Microsoft" }, "id": "aad_limited_administrative_roles", - "title": "Ensure 'Microsoft Azure Management' is limited to administrative roles" + "title": "Ensure \u0027Microsoft Azure Management\u0027 is limited to administrative roles" }, { "service": "AzureAD", @@ -62,7 +62,7 @@ "vendor": "Microsoft" }, "id": "aad_linkedin_connection_disables", - "title": "Ensure 'LinkedIn account connections' is disabled" + "title": "Ensure \u0027LinkedIn account connections\u0027 is disabled" }, { "service": "AzureAD", @@ -101,7 +101,7 @@ "vendor": "Microsoft" }, "id": "aad_phishing_MFA_strength", - "title": "Ensure 'Phishing-resistant MFA strength' is required for Administrators" + "title": "Ensure \u0027Phishing-resistant MFA strength\u0027 is required for Administrators" }, { "service": "AzureAD", @@ -1011,7 +1011,7 @@ "vendor": "Microsoft" }, "id": "admincenter_owned_apps_and_services", - "title": "Ensure 'User owned apps and services' is restricted" + "title": "Ensure \u0027User owned apps and services\u0027 is restricted" }, { "service": "AzureAD", @@ -1102,7 +1102,7 @@ "vendor": "Microsoft" }, "id": "exo_individualsharing", - "title": "Ensure 'External sharing' of calendars is not available" + "title": "Ensure \u0027External sharing\u0027 of calendars is not available" }, { "service": "EXO", @@ -1388,7 +1388,7 @@ "vendor": "Microsoft" }, "id": "MDA_CitrixSF_LoginFailLockoutSecs", - "title": "Enhance 'login maximum attempts' - Lockout timer" + "title": "Enhance \u0027login maximum attempts\u0027 - Lockout timer" }, { "service": "MDA_CitrixSF", @@ -1401,7 +1401,7 @@ "vendor": "Microsoft" }, "id": "MDA_CitrixSF_LoginFailMaxAttempts", - "title": "Enhance 'login maximum attempts' - Number of attempts" + "title": "Enhance \u0027login maximum attempts\u0027 - Number of attempts" }, { "service": "MDA_CitrixSF", @@ -1518,7 +1518,7 @@ "vendor": "Microsoft" }, "id": "MDA_GitHub_DependencyInsights", - "title": "Disable 'Allow members to view dependency insights'" + "title": "Disable \u0027Allow members to view dependency insights\u0027" }, { "service": "MDA_GitHub", @@ -1531,7 +1531,7 @@ "vendor": "Microsoft" }, "id": "MDA_GitHub_EmailNotificationRestrictedToVerifiedOrApprovedDomains", - "title": "Enabled 'email notification delivery for this enterprise is restricted to verified or approved domains'" + "title": "Enabled \u0027email notification delivery for this enterprise is restricted to verified or approved domains\u0027" }, { "service": "MDA_GitHub", @@ -1557,7 +1557,7 @@ "vendor": "Microsoft" }, "id": "MDA_GitHub_OutsideCollabInvitation", - "title": "Disable 'Allow repository administrators to invite outside collaborators to repositories for this organization" + "title": "Disable \u0027Allow repository administrators to invite outside collaborators to repositories for this organization" }, { "service": "MDA_GitHub", @@ -1583,7 +1583,7 @@ "vendor": "Microsoft" }, "id": "MDA_GitHub_PublicRepoCreation", - "title": "Disable 'Members will be able to create public repositories, visible to anyone'" + "title": "Disable \u0027Members will be able to create public repositories, visible to anyone\u0027" }, { "service": "MDA_GitHub", @@ -1596,7 +1596,7 @@ "vendor": "Microsoft" }, "id": "MDA_GitHub_RepoTransferOrDeletion", - "title": "Disable 'members with admin permissions for repositories can delete or transfer repositories'" + "title": "Disable \u0027members with admin permissions for repositories can delete or transfer repositories\u0027" }, { "service": "MDA_GitHub", @@ -1609,7 +1609,7 @@ "vendor": "Microsoft" }, "id": "MDA_GitHub_RepoVisibility_change", - "title": "Disable 'Allow members to change repository visibilities for this organization'" + "title": "Disable \u0027Allow members to change repository visibilities for this organization\u0027" }, { "service": "MDA_GitHub", @@ -2701,7 +2701,7 @@ "vendor": "Microsoft" }, "id": "mdo_connectionfilter", - "title": "Don't add allowed IP addresses in the connection filter policy " + "title": "Don\u0027t add allowed IP addresses in the connection filter policy " }, { "service": "MDO", @@ -3286,7 +3286,7 @@ "vendor": "Microsoft" }, "id": "PWAgePolicyNew", - "title": "Ensure the 'Password expiration policy' is set to 'Set passwords to never expire (recommended)'" + "title": "Ensure the \u0027Password expiration policy\u0027 is set to \u0027Set passwords to never expire (recommended)\u0027" }, { "service": "AzureAD", @@ -3301,1995 +3301,6 @@ "id": "RoleOverlap", "title": "Use least privileged administrative roles" }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_100", - "title": "Disable JavaScript on Adobe Reader 2015" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_101", - "title": "Disable JavaScript on Adobe 2015" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_15", - "title": "Enable Automatic Updates" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_16", - "title": "Enable 'Hide Option to Enable or Disable Updates'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_17", - "title": "Disable 'Allow running plugins that are outdated'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_19", - "title": "Disable 'Continue running background apps when Google Chrome is closed'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_20", - "title": "Disable 'AutoFill'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2000", - "title": "Turn on Microsoft Defender for Endpoint sensor" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2001", - "title": "Fix Microsoft Defender for Endpoint sensor data collection" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2002", - "title": "Fix Microsoft Defender for Endpoint impaired communications" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2003", - "title": "Turn on Tamper Protection" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2004", - "title": "Enable EDR in block mode" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2010", - "title": "Turn on Microsoft Defender Antivirus" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2011", - "title": "Update Microsoft Defender Antivirus definitions" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2012", - "title": "Turn on real-time protection" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2013", - "title": "Turn on PUA protection in block mode" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2014", - "title": "Fix Windows Defender Antivirus cloud service connectivity" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2016", - "title": "Enable cloud-delivered protection" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2020", - "title": "Turn on all system-level Exploit protection settings" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2021", - "title": "Set controlled folder access to enabled or audit mode" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2030", - "title": "Update Microsoft Defender for Endpoint core components" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2060", - "title": "Set Microsoft Defender SmartScreen app and file checking to block or warn" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2061", - "title": "Set Microsoft Defender SmartScreen Microsoft Edge site and download checking to block or warn" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2070", - "title": "Turn on Microsoft Defender Firewall" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2071", - "title": "Secure Microsoft Defender Firewall domain profile" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2072", - "title": "Secure Microsoft Defender firewall private profile" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2073", - "title": "Secure Microsoft Defender Firewall public profile" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2080", - "title": "Turn on Microsoft Defender Credential Guard" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2090", - "title": "Encrypt all BitLocker-supported drives" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2091", - "title": "Resume BitLocker protection on all drives" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2093", - "title": "Ensure BitLocker drive compatibility" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_21", - "title": "Block webpages from automatically running Flash plugins" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_22", - "title": "Disable 'Password Manager'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_23", - "title": "Enable 'Block third party cookies'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_24", - "title": "Set 'Remote Desktop security level' to 'TLS'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_25", - "title": "Enable 'Local Security Authority (LSA) protection'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2500", - "title": "Block executable content from email client and webmail" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2501", - "title": "Block all Office applications from creating child processes" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2502", - "title": "Block Office applications from creating executable content" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2503", - "title": "Block Office applications from injecting code into other processes" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2504", - "title": "Block JavaScript or VBScript from launching downloaded executable content" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2505", - "title": "Block execution of potentially obfuscated scripts" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2506", - "title": "Block Win32 API calls from Office macros" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2507", - "title": "Block executable files from running unless they meet a prevalence, age, or trusted list criterion" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2508", - "title": "Use advanced protection against ransomware" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2509", - "title": "Block credential stealing from the Windows local security authority subsystem (lsass.exe)" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2510", - "title": "Block process creations originating from PSExec and WMI commands" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2511", - "title": "Block untrusted and unsigned processes that run from USB" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2512", - "title": "Block Office communication application from creating child processes" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2513", - "title": "Block Adobe Reader from creating child processes" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2514", - "title": "Block persistence through WMI event subscription" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_2515", - "title": "Block abuse of exploited vulnerable signed drivers" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_26", - "title": "Enable 'Safe DLL Search Mode'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_27", - "title": "Set User Account Control (UAC) to automatically deny elevation requests" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_28", - "title": "Set 'Interactive logon: Machine inactivity limit' to '1-900 seconds'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_29", - "title": "Disable 'Enumerate administrator accounts on elevation'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_30", - "title": "Disable 'Insecure guest logons' in SMB" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_3001", - "title": "Fix unquoted service path for Windows services" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_3002", - "title": "Change service executable path to a common protected location" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_3003", - "title": "Change service account to avoid cached password in windows registry" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_3010", - "title": "Disable the built-in Administrator account" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_3011", - "title": "Disable the built-in Guest account" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_32", - "title": "Set 'Minimum password length' to '14 or more characters'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_33", - "title": "Set 'Enforce password history' to '24 or more password(s)'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_34", - "title": "Set 'Maximum password age' to '60 or fewer days, but not 0'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_35", - "title": "Set 'Minimum password age' to '1 or more day(s)'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_36", - "title": "Enable 'Domain member: Require strong (Windows 2000 or later) session key'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_37", - "title": "Enable 'Domain member: Digitally encrypt or sign secure channel data (always)'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_38", - "title": "Enable Set 'Domain member: Digitally encrypt secure channel data (when possible)'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_39", - "title": "Enable 'Domain member: Digitally sign secure channel data (when possible)'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_40", - "title": "Disable 'Domain member: Disable machine account password changes'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_4000", - "title": "Disallow offline access to shares" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_4001", - "title": "Remove share write permission set to ‘Everyone’" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_4002", - "title": "Remove shares from the root folder" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_4003", - "title": "Set folder access-based enumeration for shares" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_41", - "title": "Set 'Account lockout duration' to 15 minutes or more" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_42", - "title": "Set 'Reset account lockout counter after' to 15 minutes or more" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_43", - "title": "Disable Microsoft Defender Firewall notifications when programs are blocked for Domain profile" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_44", - "title": "Set 'Account lockout threshold' to 1-10 invalid login attempts" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_45", - "title": "Set user authentication for remote connections by using Network Level Authentication to 'Enabled'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_46", - "title": "Disable Microsoft Defender Firewall notifications when programs are blocked for Private profile" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_49", - "title": "Disable Microsoft Defender Firewall notifications when programs are blocked for Public profile" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_50", - "title": "Disable merging of local Microsoft Defender Firewall rules with group policy firewall rules for the Public profile" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_5001", - "title": "Fix Microsoft Defender for Endpoint sensor data collection in macOS" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_5002", - "title": "Fix Microsoft Defender for Endpoint impaired communications in macOS" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_5003", - "title": "Set minimum password length to 15 or more characters in macOS" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_5004", - "title": "Set 'Enforce password history' to '24 or more password(s)' in macOS" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_5005", - "title": "Set 'Maximum password age' to '90 or fewer days, but not 0' in macOS" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_5006", - "title": "Set account lockout threshold to 5 or lower in macOS" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_5007", - "title": "Turn on Firewall in macOS" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_5009", - "title": "Enable Gatekeeper in macOS" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_5010", - "title": "Enable System Integrity Protection (SIP) in macOS" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_5011", - "title": "Enable FileVault Disk Encryption in macOS" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_5013", - "title": "Ensure screensaver is set to start in 20 minutes or less in macOS" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_5014", - "title": "Secure Home Folders in macOS" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_5090", - "title": "Turn on Microsoft Defender Antivirus real-time protection in macOS" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_5091", - "title": "Turn on Microsoft Defender Antivirus PUA protection in block mode in macOS" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_5094", - "title": "Enable Microsoft Defender Antivirus cloud-delivered protection in macOS" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_5095", - "title": "Update Microsoft Defender Antivirus definitions in macOS" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_51", - "title": "Disable merging of local Microsoft Defender Firewall connection rules with group policy firewall rules for the Public profile" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_52", - "title": "Enable 'Apply UAC restrictions to local accounts on network logons'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_53", - "title": "Disable SMBv1 client driver" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_54", - "title": "Disable SMBv1 server" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_55", - "title": "Disable 'Network access: Let Everyone permissions apply to anonymous users'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_57", - "title": "Disable 'WDigest Authentication'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_58", - "title": "Disable 'Installation and configuration of Network Bridge on your DNS domain network'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_59", - "title": "Enable 'Require domain users to elevate when setting a network's location'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_60", - "title": "Prohibit use of Internet Connection Sharing on your DNS domain network" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_6001", - "title": "Fix Microsoft Defender for Endpoint sensor data collection for Linux" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_6002", - "title": "Fix Microsoft Defender for Endpoint impaired communications for Linux" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_6014", - "title": "Unrestricted Access Accounts for Linux" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_6090", - "title": "Turn on Microsoft Defender Antivirus real-time protection for Linux" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_6091", - "title": "Turn on Microsoft Defender Antivirus PUA protection in block mode for Linux" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_6094", - "title": "Enable Microsoft Defender Antivirus cloud-delivered protection for Linux" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_6095", - "title": "Update Microsoft Defender Antivirus definitions for Linux" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_61", - "title": "Set 'Minimum PIN length for startup' to '6 or more characters'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_62", - "title": "Enable 'Require additional authentication at startup'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_63", - "title": "Disable 'Configure Offer Remote Assistance'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_64", - "title": "Restrict anonymous access to named pipes and Shares" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_65", - "title": "Disable 'Store LAN Manager hash value on next password change'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_66", - "title": "Disable 'Always install with elevated privileges'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_67", - "title": "Disable 'Autoplay for non-volume devices'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_68", - "title": "Disable 'Anonymous enumeration of SAM accounts'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_69", - "title": "Disable 'Autoplay' for all drives" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_70", - "title": "Set default behavior for 'AutoRun' to 'Enabled: Do not execute any autorun commands'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_71", - "title": "Enable 'Limit local account use of blank passwords to console logon only'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_72", - "title": "Set LAN Manager authentication level to 'Send NTLMv2 response only. Refuse LM & NTLM'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_73", - "title": "Disable 'Allow Basic authentication' for WinRM Client" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_74", - "title": "Disable 'Allow Basic authentication' for WinRM Service" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_75", - "title": "Disable Flash on Adobe Reader DC" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_76", - "title": "Disable JavaScript on Adobe Reader DC" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_77", - "title": "Disable Flash on Adobe Acrobat Pro XI" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_78", - "title": "Disable JavaScript on Adobe Acrobat Pro XI" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_79", - "title": "Disable running or installing downloaded software with invalid signature" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_80", - "title": "Block Flash activation in Office documents" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_81", - "title": "Set IPv6 source routing to highest protection" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_82", - "title": "Disable IP source routing" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_83", - "title": "Enable Explorer Data Execution Prevention (DEP)" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_85", - "title": "Block outdated ActiveX controls for Internet Explorer" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_87", - "title": "Disable Solicited Remote Assistance" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_88", - "title": "Disable Anonymous enumeration of shares" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_89", - "title": "Enable scanning of removable drives during a full scan" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_9", - "title": "Enable 'Local Machine Zone Lockdown Security'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_90", - "title": "Enable Microsoft Defender Antivirus email scanning" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_91", - "title": "Enable Microsoft Defender Antivirus real-time behavior monitoring" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_92", - "title": "Enable Microsoft Defender Antivirus scanning of downloaded files and attachments" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_93", - "title": "Disable the local storage of passwords and credentials" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_94", - "title": "Disable sending unencrypted password to third-party SMB servers" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_95", - "title": "Enable 'Microsoft network client: Digitally sign communications (always)'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_96", - "title": "Enable 'Network Protection'" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_97", - "title": "Disable JavaScript on Adobe DC" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_98", - "title": "Disable JavaScript on Adobe Reader 2017" - }, - { - "service": "MDATP", - "tier": "Core", - "userImpact": "Unknown", - "vendorInformation": { - "provider": "SecureScore", - "providerVersion": null, - "subProvider": null, - "vendor": "Microsoft" - }, - "id": "scid_99", - "title": "Disable JavaScript on Adobe Acrobat 2017" - }, { "service": "AzureAD", "tier": "Defense In Depth", @@ -5301,7 +3312,7 @@ "vendor": "Microsoft" }, "id": "SelfServicePasswordReset", - "title": "Ensure 'Self service password reset enabled' is set to 'All'" + "title": "Ensure \u0027Self service password reset enabled\u0027 is set to \u0027All\u0027" }, { "service": "AzureAD", @@ -5353,7 +3364,7 @@ "vendor": "Microsoft" }, "id": "spo_external_users_sharing", - "title": "Ensure that SharePoint guest users cannot share items they don't own" + "title": "Ensure that SharePoint guest users cannot share items they don\u0027t own" }, { "service": "SPO", diff --git a/public/sponsors/relentless-dark.png b/public/sponsors/relentless-dark.png new file mode 100644 index 000000000000..b234ac7c7903 Binary files /dev/null and b/public/sponsors/relentless-dark.png differ diff --git a/public/sponsors/relentless-light.png b/public/sponsors/relentless-light.png new file mode 100644 index 000000000000..5884597febe7 Binary files /dev/null and b/public/sponsors/relentless-light.png differ diff --git a/public/version.json b/public/version.json index 25ebf6510f79..ad15f02cceba 100644 --- a/public/version.json +++ b/public/version.json @@ -1,3 +1,3 @@ { - "version": "8.7.0" -} + "version": "10.0.9" +} \ No newline at end of file diff --git a/public/version_latest.txt b/public/version_latest.txt deleted file mode 100644 index 5e39348ef037..000000000000 --- a/public/version_latest.txt +++ /dev/null @@ -1 +0,0 @@ -99.99.99 \ No newline at end of file diff --git a/src/api/ApiCall.jsx b/src/api/ApiCall.jsx index e4d9a366908c..e57fb516b9ac 100644 --- a/src/api/ApiCall.jsx +++ b/src/api/ApiCall.jsx @@ -50,7 +50,7 @@ export function ApiGetCall(props) { title: `${ error.config?.params?.tenantFilter ? error.config?.params?.tenantFilter : "" } Error`, - }) + }), ); } return returnRetry; @@ -176,7 +176,7 @@ export function ApiPostCall({ relatedQueryKeys, onResult }) { const response = await axios.post(url, element, { headers: await buildVersionedHeaders(), }); - results.push(response); + results.push(response.data); if (onResult) { onResult(response.data); // Emit each result as it arrives } @@ -211,7 +211,7 @@ export function ApiPostCall({ relatedQueryKeys, onResult }) { if (!query.queryKey || !query.queryKey[0]) return false; const queryKeyStr = String(query.queryKey[0]); const matches = wildcardPatterns.some((pattern) => - queryKeyStr.startsWith(pattern) + queryKeyStr.startsWith(pattern), ); // Debug logging for each query check @@ -220,7 +220,7 @@ export function ApiPostCall({ relatedQueryKeys, onResult }) { queryKey: query.queryKey, queryKeyStr, matchedPattern: wildcardPatterns.find((pattern) => - queryKeyStr.startsWith(pattern) + queryKeyStr.startsWith(pattern), ), }); } @@ -252,8 +252,9 @@ export function ApiGetCallWithPagination({ waiting = true, }) { const dispatch = useDispatch(); + const queryClient = useQueryClient(); const MAX_RETRIES = retry; - const HTTP_STATUS_TO_NOT_RETRY = [401, 403, 404]; + const HTTP_STATUS_TO_NOT_RETRY = [302, 401, 403, 404, 500]; const retryFn = (failureCount, error) => { let returnRetry = true; @@ -261,6 +262,12 @@ export function ApiGetCallWithPagination({ returnRetry = false; } if (isAxiosError(error) && HTTP_STATUS_TO_NOT_RETRY.includes(error.response?.status ?? 0)) { + if ( + error.response?.status === 302 && + error.response?.headers.get("location").includes("/.auth/login/aad") + ) { + queryClient.invalidateQueries({ queryKey: ["authmecipp"] }); + } returnRetry = false; } @@ -270,7 +277,7 @@ export function ApiGetCallWithPagination({ message: getCippError(error), title: "Error", toastError: error, - }) + }), ); } return returnRetry; diff --git a/src/components/CippCards/CippBannerListCard.jsx b/src/components/CippCards/CippBannerListCard.jsx index 7c96e641df6e..55f5c2ecbae6 100644 --- a/src/components/CippCards/CippBannerListCard.jsx +++ b/src/components/CippCards/CippBannerListCard.jsx @@ -3,6 +3,7 @@ import { useState, useCallback } from "react"; import { Box, Card, + Checkbox, Collapse, Divider, IconButton, @@ -16,13 +17,34 @@ import { CippPropertyListCard } from "./CippPropertyListCard"; import { CippDataTable } from "../CippTable/CippDataTable"; export const CippBannerListCard = (props) => { - const { items = [], isCollapsible = false, isFetching = false, children, ...other } = props; + const { + items = [], + isCollapsible = false, + isFetching = false, + children, + onSelectionChange, + selectedItems = [], + ...other + } = props; const [expanded, setExpanded] = useState(null); const handleExpand = useCallback((itemId) => { setExpanded((prevState) => (prevState === itemId ? null : itemId)); }, []); + const handleCheckboxChange = useCallback( + (itemId, checked) => { + if (onSelectionChange) { + if (checked) { + onSelectionChange([...selectedItems, itemId]); + } else { + onSelectionChange(selectedItems.filter((id) => id !== itemId)); + } + } + }, + [onSelectionChange, selectedItems] + ); + const hasItems = items.length > 0; if (isFetching) { @@ -91,6 +113,16 @@ export const CippBannerListCard = (props) => { alignItems="center" sx={{ flex: 1, minWidth: 0 }} > + {onSelectionChange && ( + { + e.stopPropagation(); + handleCheckboxChange(item.id, e.target.checked); + }} + onClick={(e) => e.stopPropagation()} + /> + )} { {/* Main Text and Subtext */} - + { }, }} > - + {item?.icon && ( - + {item.icon} )} @@ -48,14 +52,27 @@ export const CippInfoBar = ({ data, isFetching }) => { { if (!item?.icon) { - return { pl: 2 }; + return { pl: 2, minWidth: 0, flex: 1 }; } + return { minWidth: 0, flex: 1 }; }} > - + {item.name} - + {isFetching ? : item.data} @@ -64,14 +81,27 @@ export const CippInfoBar = ({ data, isFetching }) => { { if (!item?.icon) { - return { pl: 2 }; + return { pl: 2, minWidth: 0, flex: 1 }; } + return { minWidth: 0, flex: 1 }; }} > - + {item.name} - + {isFetching ? : item.data} diff --git a/src/components/CippCards/CippPageCard.jsx b/src/components/CippCards/CippPageCard.jsx index 004f34965127..07b278b4bdfb 100644 --- a/src/components/CippCards/CippPageCard.jsx +++ b/src/components/CippCards/CippPageCard.jsx @@ -25,27 +25,12 @@ const CippPageCard = (props) => { -
- {!hideBackButton && ( - - )} -
{hideTitleText !== true && (
{title} diff --git a/src/components/CippCards/CippUserInfoCard.jsx b/src/components/CippCards/CippUserInfoCard.jsx index 0e866efb39ac..43e4d6d26cb2 100644 --- a/src/components/CippCards/CippUserInfoCard.jsx +++ b/src/components/CippCards/CippUserInfoCard.jsx @@ -12,8 +12,8 @@ import { CircularProgress, } from "@mui/material"; import { AccountCircle, PhotoCamera, Delete } from "@mui/icons-material"; -import { PropertyList } from "/src/components/property-list"; -import { PropertyListItem } from "/src/components/property-list-item"; +import { PropertyList } from "../property-list"; +import { PropertyListItem } from "../property-list-item"; import { getCippFormatting } from "../../utils/get-cipp-formatting"; import { Stack, Grid, Box } from "@mui/system"; import { useState, useRef, useCallback } from "react"; diff --git a/src/components/CippComponents/AssessmentCard.jsx b/src/components/CippComponents/AssessmentCard.jsx new file mode 100644 index 000000000000..e3f02e29f080 --- /dev/null +++ b/src/components/CippComponents/AssessmentCard.jsx @@ -0,0 +1,129 @@ +import { Card, CardHeader, CardContent, Box, Typography, Skeleton } from "@mui/material"; +import { Security as SecurityIcon } from "@mui/icons-material"; +import { ResponsiveContainer, RadialBarChart, RadialBar, PolarAngleAxis } from "recharts"; +import { CippTimeAgo } from "../CippComponents/CippTimeAgo"; + +export const AssessmentCard = ({ data, isLoading }) => { + // Extract data with null safety + const identityPassed = data?.TestResultSummary?.IdentityPassed || 0; + const identityTotal = data?.TestResultSummary?.IdentityTotal || 1; + const devicesPassed = data?.TestResultSummary?.DevicesPassed || 0; + const devicesTotal = data?.TestResultSummary?.DevicesTotal || 0; + + // Determine if we should show devices section + const hasDeviceTests = devicesTotal > 0; + + // Calculate percentages for the radial chart + // If no device tests, set devices to 100% (complete) + const devicesPercentage = hasDeviceTests ? (devicesPassed / devicesTotal) * 100 : 100; + const identityPercentage = (identityPassed / identityTotal) * 100; + + const chartData = [ + { + value: devicesPercentage, + fill: "#22c55e", + }, + { + value: identityPercentage, + fill: "#3b82f6", + }, + ]; + + return ( + + + + Assessment + + } + sx={{ pb: 1.5 }} + /> + + + + + + Identity + + + {isLoading ? ( + + ) : ( + <> + {identityPassed}/{identityTotal} + + tests + + + )} + + + {hasDeviceTests && ( + + + Devices + + + {isLoading ? ( + + ) : ( + <> + {devicesPassed}/{devicesTotal} + + tests + + + )} + + + )} + + + Last Data Collection + + + {isLoading ? ( + + ) : data?.ExecutedAt ? ( + + ) : ( + "Not Available" + )} + + + + + {isLoading ? ( + + ) : ( + + + + + + + )} + + + + + ); +}; diff --git a/src/components/CippComponents/AuthMethodCard.jsx b/src/components/CippComponents/AuthMethodCard.jsx new file mode 100644 index 000000000000..97ade0f8d6c6 --- /dev/null +++ b/src/components/CippComponents/AuthMethodCard.jsx @@ -0,0 +1,242 @@ +import { Box, Card, CardHeader, CardContent, Typography, Skeleton } from "@mui/material"; +import { People as UsersIcon } from "@mui/icons-material"; +import { CippSankey } from "./CippSankey"; +import { useRouter } from "next/router"; + +export const AuthMethodCard = ({ data, isLoading }) => { + const router = useRouter(); + + const processData = () => { + if (!data || !Array.isArray(data) || data.length === 0) { + return null; + } + + const enabledUsers = data.filter((user) => user.AccountEnabled === true); + if (enabledUsers.length === 0) { + return null; + } + + const phishableMethods = ["mobilePhone", "email", "microsoftAuthenticatorPush"]; + const phishResistantMethods = ["fido2", "windowsHelloForBusiness", "x509Certificate"]; + + let singleFactor = 0; + let phishableCount = 0; + let phishResistantCount = 0; + let perUserMFA = 0; + let phoneCount = 0; + let authenticatorCount = 0; + let passkeyCount = 0; + let whfbCount = 0; + + enabledUsers.forEach((user) => { + const methods = Array.isArray(user.MFAMethods) ? user.MFAMethods : []; + const perUser = user.PerUser === "enforced" || user.PerUser === "enabled"; + const hasRegistered = user.MFARegistration === true; + + if (perUser && !hasRegistered && methods.length === 0) { + perUserMFA++; + return; + } + + if (!hasRegistered || methods.length === 0) { + singleFactor++; + return; + } + + const hasPhishResistant = methods.some((m) => phishResistantMethods.includes(m)); + const hasPhishable = methods.some((m) => phishableMethods.includes(m)); + + if (hasPhishResistant) { + phishResistantCount++; + if (methods.includes("fido2") || methods.includes("x509Certificate")) { + passkeyCount++; + } + if (methods.includes("windowsHelloForBusiness")) { + whfbCount++; + } + } else if (hasPhishable) { + phishableCount++; + if (methods.includes("mobilePhone") || methods.includes("email")) { + phoneCount++; + } + if ( + methods.includes("microsoftAuthenticatorPush") || + methods.includes("softwareOneTimePasscode") + ) { + authenticatorCount++; + } + } else { + phishableCount++; + authenticatorCount++; + } + }); + + const mfaPercentage = ( + ((phishableCount + phishResistantCount + perUserMFA) / enabledUsers.length) * + 100 + ).toFixed(1); + const phishResistantPercentage = ((phishResistantCount / enabledUsers.length) * 100).toFixed(1); + + const links = [ + { source: "Users", target: "Single factor", value: singleFactor }, + { source: "Users", target: "Multi factor", value: perUserMFA }, + { source: "Users", target: "Phishable", value: phishableCount }, + { source: "Users", target: "Phish resistant", value: phishResistantCount }, + ]; + + if (phoneCount > 0) links.push({ source: "Phishable", target: "Phone", value: phoneCount }); + if (authenticatorCount > 0) + links.push({ source: "Phishable", target: "Authenticator", value: authenticatorCount }); + + if (passkeyCount > 0) + links.push({ source: "Phish resistant", target: "Passkey", value: passkeyCount }); + if (whfbCount > 0) links.push({ source: "Phish resistant", target: "WHfB", value: whfbCount }); + + const description = `${mfaPercentage}% of enabled users have MFA configured. ${phishResistantPercentage}% use phish-resistant authentication methods.`; + + return { + nodes: [ + { id: "Users", nodeColor: "hsl(28, 100%, 53%)" }, + { id: "Single factor", nodeColor: "hsl(0, 100%, 50%)" }, + { id: "Multi factor", nodeColor: "hsl(200, 70%, 50%)" }, + { id: "Phishable", nodeColor: "hsl(39, 100%, 50%)" }, + { id: "Phone", nodeColor: "hsl(39, 100%, 45%)" }, + { id: "Authenticator", nodeColor: "hsl(39, 100%, 55%)" }, + { id: "Phish resistant", nodeColor: "hsl(99, 70%, 50%)" }, + { id: "Passkey", nodeColor: "hsl(140, 70%, 50%)" }, + { id: "WHfB", nodeColor: "hsl(160, 70%, 50%)" }, + ], + links, + description, + }; + }; + + const processedData = processData(); + + const handleNodeClick = (node) => { + let filters = []; + + switch (node.id) { + case "Users": + filters = [{ id: "AccountEnabled", value: "Yes" }]; + break; + case "Single factor": + filters = [ + { id: "AccountEnabled", value: "Yes" }, + { id: "MFARegistration", value: "No" }, + ]; + break; + case "Multi factor": + // Per-user MFA enabled/enforced + filters = [{ id: "AccountEnabled", value: "Yes" }]; + break; + case "Phishable": + filters = [ + { id: "AccountEnabled", value: "Yes" }, + { id: "MFARegistration", value: "Yes" }, + ]; + break; + case "Phish resistant": + filters = [ + { id: "AccountEnabled", value: "Yes" }, + { id: "MFARegistration", value: "Yes" }, + ]; + break; + default: + return; + } + + router.push({ + pathname: "/identity/reports/mfa-report", + query: { filters: JSON.stringify(filters) }, + }); + }; + + const handleLinkClick = (link) => { + let filters = []; + + if (link.source.id === "Users" && link.target.id === "Single factor") { + filters = [ + { id: "AccountEnabled", value: "Yes" }, + { id: "MFARegistration", value: "No" }, + ]; + } else if (link.source.id === "Users" && link.target.id === "Multi factor") { + filters = [{ id: "AccountEnabled", value: "Yes" }]; + } else if (link.source.id === "Users" && link.target.id === "Phishable") { + filters = [ + { id: "AccountEnabled", value: "Yes" }, + { id: "MFARegistration", value: "Yes" }, + ]; + } else if (link.source.id === "Users" && link.target.id === "Phish resistant") { + filters = [ + { id: "AccountEnabled", value: "Yes" }, + { id: "MFARegistration", value: "Yes" }, + ]; + } else if (link.source.id === "Phishable") { + filters = [ + { id: "AccountEnabled", value: "Yes" }, + { id: "MFARegistration", value: "Yes" }, + ]; + } else if (link.source.id === "Phish resistant") { + filters = [ + { id: "AccountEnabled", value: "Yes" }, + { id: "MFARegistration", value: "Yes" }, + ]; + } + + if (filters.length > 0) { + router.push({ + pathname: "/identity/reports/mfa-report", + query: { filters: JSON.stringify(filters) }, + }); + } + }; + + return ( + + + + All users auth methods + + } + sx={{ pb: 1 }} + /> + + + {isLoading ? ( + + ) : processedData ? ( + + ) : ( + + + No authentication method data available + + + )} + + + {!isLoading && processedData?.description && ( + + + {processedData.description} + + + )} + + ); +}; diff --git a/src/components/CippComponents/AuthMethodSankey.jsx b/src/components/CippComponents/AuthMethodSankey.jsx new file mode 100644 index 000000000000..f57c42573c52 --- /dev/null +++ b/src/components/CippComponents/AuthMethodSankey.jsx @@ -0,0 +1,159 @@ +import { CippSankey } from "./CippSankey"; + +export const AuthMethodSankey = ({ data }) => { + // Null safety checks + if (!data || !Array.isArray(data) || data.length === 0) { + return null; + } + + // Count enabled users only + const enabledUsers = data.filter((user) => user.AccountEnabled === true); + + if (enabledUsers.length === 0) { + return null; + } + + // Categorize MFA methods as phishable or phish-resistant + const phishableMethods = ["mobilePhone", "email", "microsoftAuthenticatorPush"]; + const phishResistantMethods = ["fido2", "windowsHelloForBusiness", "x509Certificate"]; + + let singleFactor = 0; + let phishableCount = 0; + let phishResistantCount = 0; + let perUserMFA = 0; + + // Breakdown of phishable methods + let phoneCount = 0; + let authenticatorCount = 0; + + // Breakdown of phish-resistant methods + let passkeyCount = 0; + let whfbCount = 0; + + enabledUsers.forEach((user) => { + const methods = user.MFAMethods || []; + const perUser = user.PerUser === "enforced" || user.PerUser === "enabled"; + const hasRegistered = user.MFARegistration === true; + + // If user has per-user MFA enforced but no specific methods, count as generic MFA + if (perUser && !hasRegistered && methods.length === 0) { + perUserMFA++; + return; + } + + // Check if user has any MFA methods + if (!hasRegistered || methods.length === 0) { + singleFactor++; + return; + } + + // Categorize by method type + const hasPhishResistant = methods.some((m) => phishResistantMethods.includes(m)); + const hasPhishable = methods.some((m) => phishableMethods.includes(m)); + + if (hasPhishResistant) { + phishResistantCount++; + // Count specific phish-resistant methods + if (methods.includes("fido2") || methods.includes("x509Certificate")) { + passkeyCount++; + } + if (methods.includes("windowsHelloForBusiness")) { + whfbCount++; + } + } else if (hasPhishable) { + phishableCount++; + // Count specific phishable methods + if (methods.includes("mobilePhone") || methods.includes("email")) { + phoneCount++; + } + if ( + methods.includes("microsoftAuthenticatorPush") || + methods.includes("softwareOneTimePasscode") + ) { + authenticatorCount++; + } + } else { + // Has MFA methods but not in our categorized lists + phishableCount++; + authenticatorCount++; + } + }); + + const mfaPercentage = ( + ((phishableCount + phishResistantCount + perUserMFA) / enabledUsers.length) * + 100 + ).toFixed(1); + const phishResistantPercentage = ((phishResistantCount / enabledUsers.length) * 100).toFixed(1); + + const links = [ + { source: "Users", target: "Single factor", value: singleFactor }, + { source: "Users", target: "Multi factor", value: perUserMFA }, + { source: "Users", target: "Phishable", value: phishableCount }, + { source: "Users", target: "Phish resistant", value: phishResistantCount }, + ]; + + // Add phishable method breakdowns + if (phoneCount > 0) links.push({ source: "Phishable", target: "Phone", value: phoneCount }); + if (authenticatorCount > 0) + links.push({ source: "Phishable", target: "Authenticator", value: authenticatorCount }); + + // Add phish-resistant method breakdowns + if (passkeyCount > 0) + links.push({ source: "Phish resistant", target: "Passkey", value: passkeyCount }); + if (whfbCount > 0) links.push({ source: "Phish resistant", target: "WHfB", value: whfbCount }); + + const description = `${mfaPercentage}% of enabled users have MFA configured. ${phishResistantPercentage}% use phish-resistant authentication methods.`; + + return ( + <> + + {description && ( +
+ {description} +
+ )} + + ); +}; diff --git a/src/components/CippComponents/BPASyncDialog.jsx b/src/components/CippComponents/BPASyncDialog.jsx index 43a16450416a..3add79b46f54 100644 --- a/src/components/CippComponents/BPASyncDialog.jsx +++ b/src/components/CippComponents/BPASyncDialog.jsx @@ -9,7 +9,7 @@ import { import { Sync } from "@mui/icons-material"; import { useForm, FormProvider } from "react-hook-form"; import { CippFormTenantSelector } from "./CippFormTenantSelector"; -import { ApiPostCall } from "/src/api/ApiCall"; +import { ApiPostCall } from "../../api/ApiCall"; import { CippApiResults } from "./CippApiResults"; export const BPASyncDialog = ({ createDialog }) => { diff --git a/src/components/CippComponents/BreachSearchDialog.jsx b/src/components/CippComponents/BreachSearchDialog.jsx index e089908ffb43..8a69cd40206c 100644 --- a/src/components/CippComponents/BreachSearchDialog.jsx +++ b/src/components/CippComponents/BreachSearchDialog.jsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { Dialog, DialogContent, DialogTitle, Button, DialogActions } from "@mui/material"; import { Search } from "@mui/icons-material"; import { useForm, FormProvider } from "react-hook-form"; -import { ApiPostCall } from "/src/api/ApiCall"; +import { ApiPostCall } from "../../api/ApiCall"; import { CippApiResults } from "./CippApiResults"; import { useSettings } from "../../hooks/use-settings"; diff --git a/src/components/CippComponents/CIPPM365OAuthButton.jsx b/src/components/CippComponents/CIPPM365OAuthButton.jsx index 88e517a2139d..0818190ca1cc 100644 --- a/src/components/CippComponents/CIPPM365OAuthButton.jsx +++ b/src/components/CippComponents/CIPPM365OAuthButton.jsx @@ -1,7 +1,9 @@ import { useState, useEffect } from "react"; import { Alert, Button, Typography, CircularProgress, Box } from "@mui/material"; +import { Microsoft, Login, Refresh } from "@mui/icons-material"; import { ApiGetCall } from "../../api/ApiCall"; import { CippCopyToClipBoard } from "./CippCopyToClipboard"; +import { CippApiDialog } from "./CippApiDialog"; export const CIPPM365OAuthButton = ({ onAuthSuccess, @@ -14,12 +16,14 @@ export const CIPPM365OAuthButton = ({ applicationId = null, autoStartDeviceLogon = false, validateServiceAccount = true, + promptBeforeAuth = false, }) => { const [authInProgress, setAuthInProgress] = useState(false); const [authError, setAuthError] = useState(null); const [deviceCodeInfo, setDeviceCodeInfo] = useState(null); const [codeRetrievalInProgress, setCodeRetrievalInProgress] = useState(false); const [isServiceAccount, setIsServiceAccount] = useState(true); + const [promptDialog, setPromptDialog] = useState({ open: false }); const [tokens, setTokens] = useState({ accessToken: null, refreshToken: null, @@ -32,13 +36,10 @@ export const CIPPM365OAuthButton = ({ const appIdInfo = ApiGetCall({ url: `/api/ExecListAppId`, + queryKey: "listAppId", waiting: true, }); - useEffect(() => { - appIdInfo.refetch(); - }, []); - const handleCloseError = () => { setAuthError(null); }; @@ -55,8 +56,10 @@ export const CIPPM365OAuthButton = ({ setCodeRetrievalInProgress(true); setAuthError(null); - // Refetch appId to ensure we have the latest - await appIdInfo.refetch(); + // Only refetch appId if not already present + if (!applicationId && !appIdInfo?.data?.applicationId) { + await appIdInfo.refetch(); + } try { // Get the application ID to use @@ -66,8 +69,8 @@ export const CIPPM365OAuthButton = ({ // Request device code from our API endpoint const deviceCodeResponse = await fetch( `/api/ExecDeviceCodeLogon?operation=getDeviceCode&clientId=${appId}&scope=${encodeURIComponent( - scope - )}` + scope, + )}`, ); const deviceCodeData = await deviceCodeResponse.json(); @@ -95,8 +98,10 @@ export const CIPPM365OAuthButton = ({ // Device code authentication function - opens popup and starts polling const handleDeviceCodeAuthentication = async () => { - // Refetch appId to ensure we have the latest - await appIdInfo.refetch(); + // Only refetch appId if not already present + if (!applicationId && !appIdInfo?.data?.applicationId) { + await appIdInfo.refetch(); + } if (!deviceCodeInfo) { // If we don't have a device code yet, retrieve it first @@ -129,7 +134,7 @@ export const CIPPM365OAuthButton = ({ const popup = window.open( "https://microsoft.com/devicelogin", "deviceLoginPopup", - `width=${width},height=${height},left=${left},top=${top}` + `width=${width},height=${height},left=${left},top=${top}`, ); // Start polling for token @@ -155,7 +160,7 @@ export const CIPPM365OAuthButton = ({ try { // Poll for token using our API endpoint const tokenResponse = await fetch( - `/api/ExecDeviceCodeLogon?operation=checkToken&clientId=${appId}&deviceCode=${deviceCodeInfo.device_code}` + `/api/ExecDeviceCodeLogon?operation=checkToken&clientId=${appId}&deviceCode=${deviceCodeInfo.device_code}`, ); const tokenData = await tokenResponse.json(); @@ -263,7 +268,9 @@ export const CIPPM365OAuthButton = ({ }; // MSAL-like authentication function - const handleMsalAuthentication = async () => { + const handleMsalAuthentication = async (retryCount = 0) => { + const maxRetries = 3; + // Clear previous authentication state when starting a new authentication setAuthInProgress(true); setAuthError(null); @@ -277,10 +284,12 @@ export const CIPPM365OAuthButton = ({ onmicrosoftDomain: null, }); - // Refetch app ID info to ensure we have the latest - await appIdInfo.refetch(); + // Only refetch app ID if not already present + if (!applicationId && !appIdInfo?.data?.applicationId) { + await appIdInfo.refetch(); + } - // Get the application ID to use - now we're sure to have the latest after the await + // Get the application ID to use const appId = applicationId || appIdInfo?.data?.applicationId; // Generate MSAL-like authentication parameters @@ -327,7 +336,7 @@ export const CIPPM365OAuthButton = ({ const popup = window.open( authUrl, "msalAuthPopup", - `width=${width},height=${height},left=${left},top=${top}` + `width=${width},height=${height},left=${left},top=${top}`, ); // Function to actually exchange the authorization code for tokens @@ -356,20 +365,43 @@ export const CIPPM365OAuthButton = ({ }; // Make the token request through our API proxy to avoid origin header issues - const tokenResponse = await fetch(`/api/ExecTokenExchange`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - tokenRequest, - tokenUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/token", - tenantId: appId, // Pass the tenant ID to retrieve the correct client secret - }), - }); + // Retry logic for AADSTS650051 (service principal already exists) + let retryCount = 0; + const maxRetries = 3; + let tokenResponse; + let tokenData; + + while (retryCount <= maxRetries) { + tokenResponse = await fetch(`/api/ExecTokenExchange`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + tokenRequest, + tokenUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/token", + tenantId: appId, // Pass the tenant ID to retrieve the correct client secret + }), + }); - // Parse the token response - const tokenData = await tokenResponse.json(); + // Parse the token response + tokenData = await tokenResponse.json(); + + // Check if it's the AADSTS650051 error (service principal already exists) + if ( + tokenData.error === "invalid_client" && + tokenData.error_description?.includes("AADSTS650051") + ) { + retryCount++; + if (retryCount <= maxRetries) { + // Wait before retrying (exponential backoff) + await new Promise((resolve) => setTimeout(resolve, 2000 * retryCount)); + continue; + } + } + // If no error or different error, break out of retry loop + break; + } // Check if the response contains an error if (tokenData.error) { @@ -408,6 +440,9 @@ export const CIPPM365OAuthButton = ({ if (!refreshResponse.ok) { console.warn("Failed to store refresh token, but continuing with authentication"); + } else { + // Invalidate the listAppId and tenants-table queryKeys to refresh data + appIdInfo.refetch(); } } catch (error) { console.error("Failed to store refresh token:", error); @@ -502,7 +537,27 @@ export const CIPPM365OAuthButton = ({ const errorCode = urlParams.get("error"); const errorDescription = urlParams.get("error_description"); - // Set the error state + // Check if it's the AADSTS650051 error (service principal already exists during consent) + if ( + errorCode === "invalid_client" && + errorDescription?.includes("AADSTS650051") && + retryCount < maxRetries + ) { + // Close the popup + popup.close(); + setAuthInProgress(false); + + // Wait before retrying (exponential backoff) + setTimeout( + () => { + handleMsalAuthentication(retryCount + 1); + }, + 2000 * (retryCount + 1), + ); + return; + } + + // Set the error state for non-retryable errors const error = { errorCode: errorCode, errorMessage: errorDescription || "Unknown authentication error", @@ -550,9 +605,9 @@ export const CIPPM365OAuthButton = ({
{!applicationId && !appIdInfo.isLoading && - appIdInfo?.data && // Only check if data is available + appIdInfo?.data?.applicationId && // Only check if applicationId is present in data !/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test( - appIdInfo?.data?.applicationId + appIdInfo?.data?.applicationId, ) && ( The Application ID is not valid. Please check your configuration. @@ -653,6 +708,30 @@ export const CIPPM365OAuthButton = ({ ) : null} )} + + {promptBeforeAuth !== false && ( + setPromptDialog({ open: false }), + }} + api={{ + type: "POST", + confirmText: promptBeforeAuth, + noConfirm: false, + customFunction: () => { + setPromptDialog({ open: false }); + const authFunction = useDeviceCode + ? handleDeviceCodeAuthentication + : handleMsalAuthentication; + authFunction(); + }, + }} + fields={[]} + /> + )} +
); diff --git a/src/components/CippComponents/CaDeviceSankey.jsx b/src/components/CippComponents/CaDeviceSankey.jsx new file mode 100644 index 000000000000..81f49ac38acb --- /dev/null +++ b/src/components/CippComponents/CaDeviceSankey.jsx @@ -0,0 +1,33 @@ +import { CippSankey } from "./CippSankey"; + +export const CaDeviceSankey = ({ data }) => { + return ( + + ); +}; diff --git a/src/components/CippComponents/CaSankey.jsx b/src/components/CippComponents/CaSankey.jsx new file mode 100644 index 000000000000..ffad546d8738 --- /dev/null +++ b/src/components/CippComponents/CaSankey.jsx @@ -0,0 +1,41 @@ +import { CippSankey } from "./CippSankey"; + +export const CaSankey = ({ data }) => { + return ( + + ); +}; diff --git a/src/components/CippComponents/CippAddDomainDrawer.jsx b/src/components/CippComponents/CippAddDomainDrawer.jsx new file mode 100644 index 000000000000..f5017f3fa379 --- /dev/null +++ b/src/components/CippComponents/CippAddDomainDrawer.jsx @@ -0,0 +1,121 @@ +import { useState, useEffect } from "react"; +import { Button, Box, Alert } from "@mui/material"; +import { useForm, useFormState } from "react-hook-form"; +import { AddCircleOutline } from "@mui/icons-material"; +import { CippOffCanvas } from "./CippOffCanvas"; +import { CippApiResults } from "./CippApiResults"; +import { useSettings } from "../../hooks/use-settings"; +import { ApiPostCall } from "../../api/ApiCall"; +import { Stack } from "@mui/system"; +import { CippFormComponent } from "./CippFormComponent"; + +export const CippAddDomainDrawer = ({ + buttonText = "Add Domain", + requiredPermissions = [], + PermissionButton = Button, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const userSettingsDefaults = useSettings(); + + const formControl = useForm({ + mode: "onChange", + defaultValues: { + tenantFilter: userSettingsDefaults.currentTenant, + }, + }); + + const createDomain = ApiPostCall({ + datafromUrl: true, + relatedQueryKeys: [`Domains - ${userSettingsDefaults.currentTenant}`], + }); + + const { isValid, isDirty } = useFormState({ control: formControl.control }); + + useEffect(() => { + if (createDomain.isSuccess) { + formControl.reset({ + tenantFilter: userSettingsDefaults.currentTenant, + }); + } + }, [createDomain.isSuccess]); + + const handleSubmit = (values) => { + createDomain.mutate({ + url: "/api/AddDomain", + data: values, + }); + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset({ + tenantFilter: userSettingsDefaults.currentTenant, + }); + }; + + const formFields = [ + { + type: "textField", + name: "domain", + label: "Domain Name", + placeholder: "example.com", + required: "Domain name is required", + }, + ]; + + return ( + <> + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + + +
+ + +
+ + } + > + + + Add a new domain to the current tenant. Ensure that the appropriate DNS records are + configured by checking the verification and service records after adding the domain. You + can find these in the "More info" section once the domain is added. + + + +
+ + ); +}; diff --git a/src/components/CippComponents/CippAddEditTenantGroups.jsx b/src/components/CippComponents/CippAddEditTenantGroups.jsx index 9ba637d003c8..4208c18eaf7c 100644 --- a/src/components/CippComponents/CippAddEditTenantGroups.jsx +++ b/src/components/CippComponents/CippAddEditTenantGroups.jsx @@ -1,4 +1,4 @@ -import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; +import CippFormComponent from "./CippFormComponent"; import { Typography } from "@mui/material"; import { Grid } from "@mui/system"; import { CippFormTenantSelector } from "./CippFormTenantSelector"; diff --git a/src/components/CippComponents/CippAddTestReportDrawer.jsx b/src/components/CippComponents/CippAddTestReportDrawer.jsx new file mode 100644 index 000000000000..6ea7d84ea0bb --- /dev/null +++ b/src/components/CippComponents/CippAddTestReportDrawer.jsx @@ -0,0 +1,385 @@ +import React, { useState, useEffect } from "react"; +import { + Button, + Card, + CardContent, + TextField, + Typography, + Box, + Chip, + Tab, + Tabs, + Paper, + Stack, +} from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm, useFormState, useWatch } from "react-hook-form"; +import { Add } from "@mui/icons-material"; +import { CippOffCanvas } from "./CippOffCanvas"; +import CippFormComponent from "./CippFormComponent"; +import { CippApiResults } from "./CippApiResults"; +import { ApiPostCall, ApiGetCall } from "../../api/ApiCall"; + +export const CippAddTestReportDrawer = ({ buttonText = "Create custom report" }) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const [activeTab, setActiveTab] = useState(0); + const [searchTerm, setSearchTerm] = useState(""); + + const formControl = useForm({ + mode: "onChange", + defaultValues: { + name: "", + description: "", + IdentityTests: [], + DevicesTests: [], + }, + }); + + const { isValid } = useFormState({ control: formControl.control }); + const selectedIdentityTests = + useWatch({ control: formControl.control, name: "IdentityTests" }) || []; + const selectedDeviceTests = + useWatch({ control: formControl.control, name: "DevicesTests" }) || []; + + const createReport = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: "ListTestReports", + }); + + // Fetch available tests for the form + const availableTestsApi = ApiGetCall({ + url: "/api/ListAvailableTests", + queryKey: "ListAvailableTests", + }); + + const availableTests = availableTestsApi.data || { IdentityTests: [], DevicesTests: [] }; + + // Reset form fields on successful creation + useEffect(() => { + if (createReport.isSuccess) { + formControl.reset({ + name: "", + description: "", + IdentityTests: [], + DevicesTests: [], + }); + } + }, [createReport.isSuccess, formControl]); + + const handleSubmit = () => { + formControl.trigger(); + if (!isValid) { + return; + } + + const values = formControl.getValues(); + Object.keys(values).forEach((key) => { + if (values[key] === "" || values[key] === null) { + delete values[key]; + } + }); + + createReport.mutate({ + url: "/api/AddTestReport", + data: values, + }); + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + setSearchTerm(""); + setActiveTab(0); + formControl.reset({ + name: "", + description: "", + IdentityTests: [], + DevicesTests: [], + }); + }; + + const toggleTest = (testId, testType) => { + const fieldName = testType === "Identity" ? "IdentityTests" : "DevicesTests"; + const currentTests = formControl.getValues(fieldName) || []; + + if (currentTests.includes(testId)) { + formControl.setValue( + fieldName, + currentTests.filter((id) => id !== testId), + { shouldValidate: true } + ); + } else { + formControl.setValue(fieldName, [...currentTests, testId], { shouldValidate: true }); + } + }; + + const isTestSelected = (testId, testType) => { + return testType === "Identity" + ? selectedIdentityTests.includes(testId) + : selectedDeviceTests.includes(testId); + }; + + const filterTests = (tests) => { + if (!searchTerm) return tests; + return tests.filter( + (test) => + test.id.toLowerCase().includes(searchTerm.toLowerCase()) || + test.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); + }; + + const currentTests = + activeTab === 0 + ? filterTests(availableTests.IdentityTests || []) + : filterTests(availableTests.DevicesTests || []); + + const currentTestType = activeTab === 0 ? "Identity" : "Devices"; + + return ( + <> + + + +
+ + +
+
+ } + > + + {/* Report Details Section */} + + + + Report Details + + + + + + + + + + + + + {/* Selection Summary */} + + + + + Selected Tests: + + + + + + Total: {selectedIdentityTests.length + selectedDeviceTests.length} tests + + + + + + {/* Test Selection Section */} + + + + { + setActiveTab(newValue); + setSearchTerm(""); + }} + variant="fullWidth" + > + + Identity Tests + {selectedIdentityTests.length > 0 && ( + + )} + + } + /> + + Device Tests + {selectedDeviceTests.length > 0 && ( + + )} +
+ } + /> + +
+ + {/* Search Bar */} + + setSearchTerm(e.target.value)} + /> + + + {/* Test List */} + + {availableTestsApi.isFetching ? ( + + Loading tests... + + ) : currentTests.length === 0 ? ( + + + {searchTerm ? "No tests found matching your search" : "No tests available"} + + + ) : ( + + {currentTests.map((test) => { + const isSelected = isTestSelected(test.id, currentTestType); + return ( + + toggleTest(test.id, currentTestType)} + > + + + + + + + {test.name} + + + {test.description && ( + + {test.description} + + )} + + + + + + ); + })} + + )} + + + + + + + ); +}; diff --git a/src/components/CippComponents/CippAddUserDrawer.jsx b/src/components/CippComponents/CippAddUserDrawer.jsx index b202679e545a..6e8e333b317f 100644 --- a/src/components/CippComponents/CippAddUserDrawer.jsx +++ b/src/components/CippComponents/CippAddUserDrawer.jsx @@ -3,7 +3,6 @@ import { Button, Box } from "@mui/material"; import { useForm, useWatch, useFormState } from "react-hook-form"; import { PersonAdd } from "@mui/icons-material"; import { CippOffCanvas } from "./CippOffCanvas"; -import { CippFormUserSelector } from "./CippFormUserSelector"; import { CippApiResults } from "./CippApiResults"; import { useSettings } from "../../hooks/use-settings"; import { ApiPostCall } from "../../api/ApiCall"; diff --git a/src/components/CippComponents/CippAddVacationModeDrawer.jsx b/src/components/CippComponents/CippAddVacationModeDrawer.jsx index 45fb368fe970..bd6b4397d590 100644 --- a/src/components/CippComponents/CippAddVacationModeDrawer.jsx +++ b/src/components/CippComponents/CippAddVacationModeDrawer.jsx @@ -9,7 +9,7 @@ import { CippApiResults } from "./CippApiResults"; import { CippFormUserSelector } from "./CippFormUserSelector"; import { CippFormTenantSelector } from "./CippFormTenantSelector"; import { ApiPostCall, ApiGetCallWithPagination } from "../../api/ApiCall"; -import CippJsonView from "/src/components/CippFormPages/CippJSONView"; +import CippJsonView from "../CippFormPages/CippJSONView"; export const CippAddVacationModeDrawer = ({ buttonText = "Add Vacation Schedule", @@ -28,6 +28,8 @@ export const CippAddVacationModeDrawer = ({ PolicyId: null, startDate: null, endDate: null, + reference: null, + postExecution: [], excludeLocationAuditAlerts: false, }, }); @@ -85,6 +87,8 @@ export const CippAddVacationModeDrawer = ({ PolicyId: null, startDate: null, endDate: null, + reference: null, + postExecution: [], excludeLocationAuditAlerts: false, }); } @@ -105,6 +109,8 @@ export const CippAddVacationModeDrawer = ({ StartDate: formData.startDate, EndDate: formData.endDate, vacation: true, + reference: formData.reference || null, + postExecution: formData.postExecution || [], excludeLocationAuditAlerts: formData.excludeLocationAuditAlerts || false, }; @@ -124,6 +130,8 @@ export const CippAddVacationModeDrawer = ({ PolicyId: null, startDate: null, endDate: null, + reference: null, + postExecution: [], }); }; @@ -295,6 +303,33 @@ export const CippAddVacationModeDrawer = ({ }} /> + + {/* Post Execution Actions */} + + + + + + + {policyHasLocationTarget && ( { ...other } = props; const router = useRouter(); + const linkOpenedRef = useRef(false); const [addedFieldData, setAddedFieldData] = useState({}); const [partialResults, setPartialResults] = useState([]); const [isFormSubmitted, setIsFormSubmitted] = useState(false); @@ -41,7 +42,7 @@ export const CippApiDialog = (props) => { } const formHook = useForm({ - defaultValues: defaultvalues || {}, + defaultValues: typeof defaultvalues === "function" ? defaultvalues(row) : defaultvalues || {}, mode: "onChange", // Enable real-time validation }); @@ -122,8 +123,11 @@ export const CippApiDialog = (props) => { const handleActionClick = (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); } else { if (action.multiPost === undefined) action.multiPost = false; @@ -201,7 +205,7 @@ export const CippApiDialog = (props) => { if (action.type === "POST") { actionPostRequest.mutate({ url: action.url, - bulkRequest: false, + bulkRequest: isBulkRequest, data: finalData, }); } else if (action.type === "GET") { @@ -209,7 +213,7 @@ export const CippApiDialog = (props) => { url: action.url, waiting: true, queryKey: Date.now(), - bulkRequest: false, + bulkRequest: isBulkRequest, data: finalData, }); } @@ -239,8 +243,8 @@ export const CippApiDialog = (props) => { el?.label && el?.value ? el : typeof el === "string" || typeof el === "number" - ? { label: el, value: el } - : null + ? { label: el, value: el } + : null, ) .filter(Boolean); formHook.setValue(field.name, values); @@ -250,38 +254,57 @@ export const CippApiDialog = (props) => { typeof val === "string" ? { label: val, value: val } : val.label && val.value - ? val - : undefined + ? val + : undefined, ); } }); } }, [createDialog.open, api?.setDefaultValues]); - const getNestedValue = (obj, path) => - path + const escapeHtml = (text) => { + if (typeof text !== "string") return text; + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + }; + + const getNestedValue = (obj, path) => { + const value = path .split(".") .reduce((acc, key) => (acc && acc[key] !== undefined ? acc[key] : undefined), obj); + return typeof value === "string" ? escapeHtml(value) : value; + }; - const [linkClicked, setLinkClicked] = useState(false); - useEffect(() => setLinkClicked(false), [api.link]); + // Handle link actions - opens the link when dialog opens, using ref to prevent duplicates + useEffect(() => { + if ( + api.link && + createDialog.open && + row && + Object.keys(row).length > 0 && + !linkOpenedRef.current + ) { + linkOpenedRef.current = true; + const linkWithData = api.link.replace( + /\[([^\]]+)\]/g, + (_, key) => getNestedValue(row, key) || `[${key}]`, + ); + if (linkWithData.startsWith("/") && !api?.external) { + router.push(linkWithData, undefined, { shallow: true }); + } else { + window.open(linkWithData, api.target || "_blank"); + } + createDialog.handleClose(); + } + }, [api.link, createDialog.open, row, router]); + // Reset the ref when dialog closes so the same link can be opened again useEffect(() => { - if (api.link && !linkClicked && row && Object.keys(row).length > 0) { - const timeoutId = setTimeout(() => { - const linkWithData = api.link.replace( - /\[([^\]]+)\]/g, - (_, key) => getNestedValue(row, key) || `[${key}]` - ); - setLinkClicked(true); - if (linkWithData.startsWith("/") && !api?.external) - router.push(linkWithData, undefined, { shallow: true }); - else window.open(linkWithData, api.target || "_blank"); - }, 0); - - return () => clearTimeout(timeoutId); + if (!createDialog.open) { + linkOpenedRef.current = false; } - }, [api.link, linkClicked, row, router]); + }, [createDialog.open]); useEffect(() => { if (api.noConfirm && !api.link) { @@ -300,14 +323,14 @@ export const CippApiDialog = (props) => { 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"); } else if (row.length === 1) { confirmText = api.confirmText.replace( /\[([^\]]+)\]/g, - (_, key) => getNestedValue(row[0], key) || `[${key}]` + (_, key) => getNestedValue(row[0], key) || `[${key}]`, ); } } else { @@ -319,7 +342,7 @@ export const CippApiDialog = (props) => { ? element.replace(/\[([^\]]+)\]/g, "the 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}]`); @@ -361,6 +384,7 @@ export const CippApiDialog = (props) => { formControl={formHook} addedFieldData={addedFieldData} setAddedFieldData={setAddedFieldData} + row={row} {...fieldProps} />
diff --git a/src/components/CippComponents/CippApiLogsDrawer.jsx b/src/components/CippComponents/CippApiLogsDrawer.jsx index 12e9e9e93a61..67eb80cef47e 100644 --- a/src/components/CippComponents/CippApiLogsDrawer.jsx +++ b/src/components/CippComponents/CippApiLogsDrawer.jsx @@ -10,6 +10,7 @@ export const CippApiLogsDrawer = ({ apiFilter = null, tenantFilter = null, standardFilter = null, + scheduledTaskFilter = null, requiredPermissions = [], PermissionButton = Button, title = "API Logs", @@ -28,7 +29,9 @@ export const CippApiLogsDrawer = ({ // Build the API URL with the filter const apiUrl = `/api/ListLogs?Filter=true${apiFilter ? `&API=${apiFilter}` : ""}${ tenantFilter ? `&Tenant=${tenantFilter}` : "" - }${standardFilter ? `&StandardTemplateId=${standardFilter}` : ""}`; + }${standardFilter ? `&StandardTemplateId=${standardFilter}` : ""}${ + scheduledTaskFilter ? `&ScheduledTaskId=${scheduledTaskFilter}` : "" + }`; // Define the columns for the logs table const simpleColumns = [ @@ -74,7 +77,9 @@ export const CippApiLogsDrawer = ({ url: apiUrl, dataKey: "", }} - queryKey={`APILogs-${apiFilter || "All"}`} + queryKey={`APILogs-${apiFilter || "All"}-${tenantFilter || "AllTenants"}-${ + standardFilter || "NoStandard" + }-${scheduledTaskFilter || "NoTask"}`} simpleColumns={simpleColumns} exportEnabled={true} offCanvas={{ diff --git a/src/components/CippComponents/CippApiResults.jsx b/src/components/CippComponents/CippApiResults.jsx index 388b27984ced..124f4282e1af 100644 --- a/src/components/CippComponents/CippApiResults.jsx +++ b/src/components/CippComponents/CippApiResults.jsx @@ -158,8 +158,27 @@ export const CippApiResults = (props) => { const allResults = useMemo(() => { const apiResults = extractAllResults(correctResultObj); + + // Also extract error results if there's an error + if (apiObject.isError && apiObject.error) { + const errorResults = extractAllResults(apiObject.error.response.data); + if (errorResults.length > 0) { + // Mark all error results with error severity and merge with success results + return [...apiResults, ...errorResults.map((r) => ({ ...r, severity: "error" }))]; + } + + // Fallback to getCippError if extraction didn't work + const processedError = getCippError(apiObject.error); + if (typeof processedError === "string") { + return [ + ...apiResults, + { text: processedError, copyField: processedError, severity: "error" }, + ]; + } + } + return apiResults; - }, [correctResultObj]); + }, [correctResultObj, apiObject.isError, apiObject.error]); useEffect(() => { setErrorVisible(!!apiObject.isError); @@ -250,31 +269,8 @@ export const CippApiResults = (props) => { )} - {/* Error alert */} - - {apiObject.isError && ( - setErrorVisible(false)} - > - - - } - > - {getCippError(apiObject.error)} - - )} - - {/* Individual result alerts */} - {apiObject.isSuccess && !errorsOnly && hasVisibleResults && ( + {hasVisibleResults && ( <> {finalResults.map((resultObj) => ( diff --git a/src/components/CippComponents/CippAppPermissionBuilder.jsx b/src/components/CippComponents/CippAppPermissionBuilder.jsx index 46adfc153008..3ff6635eec32 100644 --- a/src/components/CippComponents/CippAppPermissionBuilder.jsx +++ b/src/components/CippComponents/CippAppPermissionBuilder.jsx @@ -17,7 +17,7 @@ import { Tab, } from "@mui/material"; import { Grid } from "@mui/system"; -import { ApiGetCall, ApiPostCall } from "/src/api/ApiCall"; +import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; import { CippDataTable } from "../CippTable/CippDataTable"; import { PlusIcon, ShieldCheckIcon, WrenchIcon } from "@heroicons/react/24/outline"; import CippFormComponent from "./CippFormComponent"; diff --git a/src/components/CippComponents/CippApplicationDeployDrawer.jsx b/src/components/CippComponents/CippApplicationDeployDrawer.jsx index b8b095937725..6b51f4889e57 100644 --- a/src/components/CippComponents/CippApplicationDeployDrawer.jsx +++ b/src/components/CippComponents/CippApplicationDeployDrawer.jsx @@ -8,7 +8,7 @@ import CippFormComponent from "./CippFormComponent"; import { CippFormTenantSelector } from "./CippFormTenantSelector"; import { CippFormCondition } from "./CippFormCondition"; import { CippApiResults } from "./CippApiResults"; -import languageList from "/src/data/languageList.json"; +import languageList from "../../data/languageList.json"; import { ApiPostCall } from "../../api/ApiCall"; export const CippApplicationDeployDrawer = ({ @@ -241,7 +241,7 @@ export const CippApplicationDeployDrawer = ({ - Provide a custom Office Configuration XML. When using custom XML, all other - Office configuration options above will be ignored. See{" "} - + Provide a custom Office Configuration XML. When using custom XML, all other Office + configuration options above will be ignored. See{" "} + Office Customization Tool {" "} to generate XML. diff --git a/src/components/CippComponents/CippAuditLogDetails.jsx b/src/components/CippComponents/CippAuditLogDetails.jsx index b5a3077a0da6..44a291245cb7 100644 --- a/src/components/CippComponents/CippAuditLogDetails.jsx +++ b/src/components/CippComponents/CippAuditLogDetails.jsx @@ -1,10 +1,10 @@ import { useEffect } from "react"; -import { getCippTranslation } from "/src/utils/get-cipp-translation"; -import { getCippFormatting } from "/src/utils/get-cipp-formatting"; -import CippGeoLocation from "/src/components/CippComponents/CippGeoLocation"; +import { getCippTranslation } from "../../utils/get-cipp-translation"; +import { getCippFormatting } from "../../utils/get-cipp-formatting"; +import CippGeoLocation from "./CippGeoLocation"; import { Tooltip, CircularProgress, Stack } from "@mui/material"; -import { useGuidResolver } from "/src/hooks/use-guid-resolver"; -import { CippPropertyListCard } from "/src/components/CippCards/CippPropertyListCard"; +import { useGuidResolver } from "../../hooks/use-guid-resolver"; +import { CippPropertyListCard } from "../CippCards/CippPropertyListCard"; const CippAuditLogDetails = ({ row }) => { const { diff --git a/src/components/CippComponents/CippAuditLogSearchDrawer.jsx b/src/components/CippComponents/CippAuditLogSearchDrawer.jsx index 93628842b386..9c3179f0490d 100644 --- a/src/components/CippComponents/CippAuditLogSearchDrawer.jsx +++ b/src/components/CippComponents/CippAuditLogSearchDrawer.jsx @@ -6,7 +6,7 @@ import { CippOffCanvas } from "./CippOffCanvas"; import { ApiPostCall, ApiGetCallWithPagination } from "../../api/ApiCall"; import CippFormComponent from "./CippFormComponent"; import { CippApiResults } from "./CippApiResults"; -import { useSettings } from "/src/hooks/use-settings"; +import { useSettings } from "../../hooks/use-settings"; export const CippAuditLogSearchDrawer = ({ buttonText = "New Search", diff --git a/src/components/CippComponents/CippAutocomplete.jsx b/src/components/CippComponents/CippAutocomplete.jsx index 4532c6a035bb..ea5d9811bda8 100644 --- a/src/components/CippComponents/CippAutocomplete.jsx +++ b/src/components/CippComponents/CippAutocomplete.jsx @@ -6,6 +6,8 @@ import { TextField, IconButton, Tooltip, + Box, + Typography, } from "@mui/material"; import { useEffect, useState, useMemo, useCallback, useRef } from "react"; import { useSettings } from "../../hooks/use-settings"; @@ -26,29 +28,31 @@ const MemoTextField = React.memo(function MemoTextField({ const { InputProps, ...otherParams } = params; return ( - + + }} + /> + ); }); @@ -187,6 +191,12 @@ export const CippAutoComplete = (props) => { typeof api?.valueField === "function" ? api.valueField(option) : option[api?.valueField], + description: + typeof api?.descriptionField === "function" + ? api.descriptionField(option) + : api?.descriptionField + ? option[api?.descriptionField] + : undefined, addedFields, rawData: option, // Store the full original object }; @@ -543,6 +553,21 @@ export const CippAutoComplete = (props) => { )} groupBy={groupBy} renderGroup={renderGroup} + renderOption={(props, option) => { + const { key, ...optionProps } = props; + return ( + + + {option.label} + {option.description && ( + + {option.description} + + )} + + + ); + }} {...other} /> {api?.templateView && ( diff --git a/src/components/CippComponents/CippAutopilotProfileDrawer.jsx b/src/components/CippComponents/CippAutopilotProfileDrawer.jsx index f5b48ae54040..7edc95b46b60 100644 --- a/src/components/CippComponents/CippAutopilotProfileDrawer.jsx +++ b/src/components/CippComponents/CippAutopilotProfileDrawer.jsx @@ -7,7 +7,7 @@ import { CippOffCanvas } from "./CippOffCanvas"; import CippFormComponent from "./CippFormComponent"; import { CippFormTenantSelector } from "./CippFormTenantSelector"; import { CippApiResults } from "./CippApiResults"; -import languageList from "/src/data/languageList.json"; +import languageList from "../../data/languageList.json"; import { ApiPostCall } from "../../api/ApiCall"; export const CippAutopilotProfileDrawer = ({ diff --git a/src/components/CippComponents/CippBreadcrumbNav.jsx b/src/components/CippComponents/CippBreadcrumbNav.jsx new file mode 100644 index 000000000000..98093aa34974 --- /dev/null +++ b/src/components/CippComponents/CippBreadcrumbNav.jsx @@ -0,0 +1,744 @@ +import { useEffect, useState, useRef } from "react"; +import { useRouter } from "next/router"; +import { Breadcrumbs, Link, Typography, Box, IconButton, Tooltip } from "@mui/material"; +import { NavigateNext, History, AccountTree } from "@mui/icons-material"; +import { nativeMenuItems } from "../../layouts/config"; +import { useSettings } from "../../hooks/use-settings"; + +const MAX_HISTORY_STORAGE = 20; // Maximum number of pages to keep in history +const MAX_BREADCRUMB_DISPLAY = 5; // Maximum number of breadcrumbs to display at once + +/** + * Load all tabOptions.json files dynamically + */ +async function loadTabOptions() { + const tabOptionPaths = [ + "/email/administration/exchange-retention", + "/cipp/custom-data", + "/cipp/super-admin", + "/tenant/standards", + "/tenant/manage", + "/tenant/administration/applications", + "/tenant/administration/tenants", + "/tenant/administration/audit-logs", + "/identity/administration/users/user", + "/tenant/administration/securescore", + "/tenant/gdap-management", + "/tenant/gdap-management/relationships/relationship", + "/cipp/settings", + ]; + + const tabOptions = []; + + for (const basePath of tabOptionPaths) { + try { + const module = await import(`../../pages${basePath}/tabOptions.json`); + const options = module.default || module; + + // Add each tab option with metadata + options.forEach((option) => { + tabOptions.push({ + title: option.label, + path: option.path, + type: "tab", + basePath: basePath, + }); + }); + } catch (error) { + // Silently skip if file doesn't exist or can't be loaded + } + } + + return tabOptions; +} + +export const CippBreadcrumbNav = () => { + const router = useRouter(); + const settings = useSettings(); + const [history, setHistory] = useState([]); + const [mode, setMode] = useState(settings.breadcrumbMode || "hierarchical"); + const [tabOptions, setTabOptions] = useState([]); + const lastRouteRef = useRef(null); + const titleCheckCountRef = useRef(0); + const titleCheckIntervalRef = useRef(null); + + // Helper function to filter out unnecessary query parameters + const getCleanQueryParams = (query) => { + const cleaned = { ...query }; + // Remove tenantFilter if it's "AllTenants" or not explicitly needed + if (cleaned.tenantFilter === "AllTenants" || cleaned.tenantFilter === undefined) { + delete cleaned.tenantFilter; + } + return cleaned; + }; + + // Helper function to clean page titles + const cleanPageTitle = (title) => { + if (!title) return title; + // Remove AllTenants and any surrounding separators + return title + .replace(/\s*-\s*AllTenants\s*/, "") + .replace(/AllTenants\s*-\s*/, "") + .replace(/AllTenants/, "") + .trim(); + }; + + // Load tab options on mount + useEffect(() => { + loadTabOptions().then(setTabOptions); + }, []); + + useEffect(() => { + // Only update when the route actually changes, not on every render + const currentRoute = router.asPath; + + // Skip if this is the same route as last time + if (lastRouteRef.current === currentRoute) { + return; + } + + lastRouteRef.current = currentRoute; + + // Clear any existing title check interval + if (titleCheckIntervalRef.current) { + clearInterval(titleCheckIntervalRef.current); + titleCheckIntervalRef.current = null; + } + + // Reset check counter + titleCheckCountRef.current = 0; + + // Function to check and update title + const checkTitle = () => { + titleCheckCountRef.current++; + + // Stop checking after 50 attempts (5 seconds) to prevent infinite intervals + if (titleCheckCountRef.current > 50) { + if (titleCheckIntervalRef.current) { + clearInterval(titleCheckIntervalRef.current); + titleCheckIntervalRef.current = null; + } + return; + } + + let pageTitle = document.title.replace(" - CIPP", "").trim(); + + // Remove tenant domain from title (e.g., "Groups - domain.onmicrosoft.com" -> "Groups") + // But only if it looks like a domain (contains a dot) + const parts = pageTitle.split(" - "); + if (parts.length > 1 && parts[parts.length - 1].includes(".")) { + pageTitle = parts.slice(0, -1).join(" - ").trim(); + } + + // Clean AllTenants from title + pageTitle = cleanPageTitle(pageTitle); + + // Skip if title is empty, generic, or error page + if ( + !pageTitle || + pageTitle === "CIPP" || + pageTitle.toLowerCase().includes("error") || + pageTitle === "404" || + pageTitle === "500" + ) { + return; + } + + // Normalize URL for comparison (remove trailing slashes and query params) + const normalizeUrl = (url) => { + // Remove query params and trailing slashes for comparison + return url.split("?")[0].replace(/\/$/, "").toLowerCase(); + }; + + const currentPage = { + title: pageTitle, + path: router.pathname, + query: { ...router.query }, + fullUrl: router.asPath, + timestamp: Date.now(), + }; + + const normalizedCurrentUrl = normalizeUrl(currentPage.fullUrl); + + setHistory((prevHistory) => { + // Check if last entry has same title AND similar path (prevent duplicate with same content) + const lastEntry = prevHistory[prevHistory.length - 1]; + if (lastEntry) { + const sameTitle = lastEntry.title.trim() === currentPage.title.trim(); + const samePath = normalizeUrl(lastEntry.fullUrl) === normalizedCurrentUrl; + + if (sameTitle && samePath) { + // Exact duplicate - don't add, just stop checking + if (titleCheckIntervalRef.current) { + clearInterval(titleCheckIntervalRef.current); + titleCheckIntervalRef.current = null; + } + return prevHistory; + } + + if (samePath && !sameTitle) { + // Same URL but title changed - update the entry + const updated = [...prevHistory]; + updated[prevHistory.length - 1] = { + ...currentPage, + query: getCleanQueryParams(currentPage.query), + }; + if (titleCheckIntervalRef.current) { + clearInterval(titleCheckIntervalRef.current); + titleCheckIntervalRef.current = null; + } + return updated; + } + } + + // Find if this URL exists anywhere EXCEPT the last position in history + const existingIndex = prevHistory.findIndex((entry, index) => { + // Skip the last entry since we already checked it above + if (index === prevHistory.length - 1) return false; + return normalizeUrl(entry.fullUrl) === normalizedCurrentUrl; + }); + + // URL not in history (except possibly as last entry which we handled) - add as new entry + if (existingIndex === -1) { + const cleanedCurrentPage = { + ...currentPage, + query: getCleanQueryParams(currentPage.query), + }; + const newHistory = [...prevHistory, cleanedCurrentPage]; + + // Keep only the last MAX_HISTORY_STORAGE pages + const trimmedHistory = + newHistory.length > MAX_HISTORY_STORAGE + ? newHistory.slice(-MAX_HISTORY_STORAGE) + : newHistory; + + // Don't stop checking yet - title might still be loading + return trimmedHistory; + } + + // URL exists in history but not as last entry - user navigated back + // Truncate history after this point and update the entry + if (titleCheckIntervalRef.current) { + clearInterval(titleCheckIntervalRef.current); + titleCheckIntervalRef.current = null; + } + const updated = prevHistory.slice(0, existingIndex + 1); + updated[existingIndex] = { + ...currentPage, + query: getCleanQueryParams(currentPage.query), + }; + return updated; + }); + }; + + // Start checking for title updates + titleCheckIntervalRef.current = setInterval(checkTitle, 100); + + return () => { + if (titleCheckIntervalRef.current) { + clearInterval(titleCheckIntervalRef.current); + titleCheckIntervalRef.current = null; + } + }; + }, [router.asPath, router.pathname, router.query]); + + const handleBreadcrumbClick = (index) => { + const page = history[index]; + if (page) { + const cleanedQuery = getCleanQueryParams(page.query); + router.push({ + pathname: page.path, + query: cleanedQuery, + }); + } + }; + + // State to track current page title for hierarchical mode + const [currentPageTitle, setCurrentPageTitle] = useState(null); + const hierarchicalTitleCheckRef = useRef(null); + const hierarchicalCheckCountRef = useRef(0); + + // Watch for title changes to update hierarchical breadcrumbs + useEffect(() => { + if (mode === "hierarchical") { + // Clear any existing interval + if (hierarchicalTitleCheckRef.current) { + clearInterval(hierarchicalTitleCheckRef.current); + hierarchicalTitleCheckRef.current = null; + } + + // Reset counter + hierarchicalCheckCountRef.current = 0; + + const updateTitle = () => { + hierarchicalCheckCountRef.current++; + + // Stop after 20 attempts (10 seconds) to prevent infinite checking + if (hierarchicalCheckCountRef.current > 20) { + if (hierarchicalTitleCheckRef.current) { + clearInterval(hierarchicalTitleCheckRef.current); + hierarchicalTitleCheckRef.current = null; + } + return; + } + + let pageTitle = document.title.replace(" - CIPP", "").trim(); + const parts = pageTitle.split(" - "); + const cleanTitle = + parts.length > 1 && parts[parts.length - 1].includes(".") + ? parts.slice(0, -1).join(" - ").trim() + : pageTitle; + + // Clean AllTenants from title + const finalTitle = cleanPageTitle(cleanTitle); + + if (finalTitle && finalTitle !== "CIPP" && !finalTitle.toLowerCase().includes("loading")) { + setCurrentPageTitle(finalTitle); + // Stop checking once we have a valid title + if (hierarchicalTitleCheckRef.current) { + clearInterval(hierarchicalTitleCheckRef.current); + hierarchicalTitleCheckRef.current = null; + } + } + }; + + // Initial update + updateTitle(); + + // Only start interval if we don't have a valid title yet + if (!currentPageTitle || currentPageTitle.toLowerCase().includes("loading")) { + hierarchicalTitleCheckRef.current = setInterval(updateTitle, 500); + } + + return () => { + if (hierarchicalTitleCheckRef.current) { + clearInterval(hierarchicalTitleCheckRef.current); + hierarchicalTitleCheckRef.current = null; + } + }; + } + }, [mode, router.pathname]); + + // Build hierarchical breadcrumbs from config.js navigation structure + const buildHierarchicalBreadcrumbs = () => { + const currentPath = router.pathname; + + // Helper to check if paths match (handles dynamic routes) + const pathsMatch = (menuPath, currentPath) => { + if (!menuPath) return false; + + // Exact match + if (menuPath === currentPath) return true; + + // Check if current path starts with menu path (for nested routes) + // e.g., menu: "/identity/administration/users" matches "/identity/administration/users/edit" + if (currentPath.startsWith(menuPath + "/")) return true; + + return false; + }; + + const findPathInMenu = (items, path = []) => { + for (const item of items) { + const currentBreadcrumb = [...path]; + + // Add current item to path if it has a title + // Include all items (headers, groups, and pages) to show full hierarchy + if (item.title) { + currentBreadcrumb.push({ + title: item.title, + path: item.path, + type: item.type, + query: {}, // Menu items don't have query params by default + }); + } + + // Check if this item matches the current path + if (item.path && pathsMatch(item.path, currentPath)) { + // If this is the current page, include current query params (cleaned) + if (item.path === currentPath) { + const lastItem = currentBreadcrumb[currentBreadcrumb.length - 1]; + if (lastItem) { + lastItem.query = getCleanQueryParams(router.query); + } + } + return currentBreadcrumb; + } + + // Recursively search children + if (item.items && item.items.length > 0) { + const result = findPathInMenu(item.items, currentBreadcrumb); + if (result.length > 0) { + return result; + } + } + } + return []; + }; + + let result = findPathInMenu(nativeMenuItems); + + // If we found a menu item, check if the current path matches any tab + // If so, tabOptions wins and we use its label + if (result.length > 0 && tabOptions.length > 0) { + const normalizedCurrentPath = currentPath.replace(/\/$/, ""); + + // Check if current path matches any tab (exact match) + const matchingTab = tabOptions.find((tab) => { + const normalizedTabPath = tab.path.replace(/\/$/, ""); + return normalizedTabPath === normalizedCurrentPath; + }); + + if (matchingTab) { + // Tab matches the current path - use tab's label instead of config's + result = result.map((item, idx) => { + if (idx === result.length - 1) { + return { + ...item, + title: matchingTab.title, + type: "tab", + }; + } + return item; + }); + } + } + + // If not found in main menu, check if it's a tab page + if (result.length === 0 && tabOptions.length > 0) { + const normalizedCurrentPath = currentPath.replace(/\/$/, ""); + + // Find matching tab option + const matchingTab = tabOptions.find((tab) => { + const normalizedTabPath = tab.path.replace(/\/$/, ""); + return normalizedTabPath === normalizedCurrentPath; + }); + + if (matchingTab) { + // Find the base page in the menu and build full path to it + const normalizedBasePath = matchingTab.basePath?.replace(/\/$/, ""); + + // Recursively find the base page and build breadcrumb path + const findBasePageWithPath = (items, path = []) => { + for (const item of items) { + const currentBreadcrumb = [...path]; + + // Add current item to path if it has a title + if (item.title) { + currentBreadcrumb.push({ + title: item.title, + path: item.path, + type: item.type, + query: {}, // Menu items don't have query params by default + }); + } + + // Check if this item matches the base path + if (item.path) { + const normalizedItemPath = item.path.replace(/\/$/, ""); + if ( + normalizedItemPath === normalizedBasePath || + normalizedItemPath.startsWith(normalizedBasePath) + ) { + return currentBreadcrumb; + } + } + + // Recursively search children + if (item.items && item.items.length > 0) { + const found = findBasePageWithPath(item.items, currentBreadcrumb); + if (found.length > 0) { + return found; + } + } + } + return []; + }; + + const basePagePath = findBasePageWithPath(nativeMenuItems); + + if (basePagePath.length > 0) { + result = basePagePath; + + // Add the tab as the final breadcrumb with current query params (cleaned) + result.push({ + title: matchingTab.title, + path: matchingTab.path, + type: "tab", + query: getCleanQueryParams(router.query), // Include current query params for tab page + }); + } + } + } + + // Check if we're on a nested page under a menu item (e.g., edit page) + if (result.length > 0) { + const lastItem = result[result.length - 1]; + if (lastItem.path && lastItem.path !== currentPath && currentPath.startsWith(lastItem.path)) { + // Use the tracked page title if available, otherwise fall back to document.title + let tabTitle = currentPageTitle || document.title.replace(" - CIPP", "").trim(); + + // Clean AllTenants from title + tabTitle = cleanPageTitle(tabTitle); + + // Add tab as an additional breadcrumb item + if ( + tabTitle && + tabTitle !== lastItem.title && + !tabTitle.toLowerCase().includes("loading") + ) { + result.push({ + title: tabTitle, + path: currentPath, + type: "tab", + query: getCleanQueryParams(router.query), // Include current query params (cleaned) + }); + } + } + } + + return result; + }; + + // Check if a path is valid and return its title from navigation or tabs + const getPathInfo = (path) => { + if (!path) return { isValid: false, title: null }; + + const normalizedPath = path.replace(/\/$/, ""); + + // Helper function to recursively search menu items + const findInMenu = (items) => { + for (const item of items) { + if (item.path) { + const normalizedItemPath = item.path.replace(/\/$/, ""); + if (normalizedItemPath === normalizedPath) { + return { isValid: true, title: item.title }; + } + } + if (item.items && item.items.length > 0) { + const found = findInMenu(item.items); + if (found.isValid) { + return found; + } + } + } + return { isValid: false, title: null }; + }; + + // Check if path exists in navigation + const menuResult = findInMenu(nativeMenuItems); + if (menuResult.isValid) { + return menuResult; + } + + // Check if path exists in tab options + const matchingTab = tabOptions.find((tab) => tab.path.replace(/\/$/, "") === normalizedPath); + if (matchingTab) { + return { isValid: true, title: matchingTab.title }; + } + + return { isValid: false, title: null }; + }; + + // Handle click for hierarchical breadcrumbs + const handleHierarchicalClick = (path, query) => { + if (path) { + const cleanedQuery = getCleanQueryParams(query); + if (cleanedQuery && Object.keys(cleanedQuery).length > 0) { + router.push({ + pathname: path, + query: cleanedQuery, + }); + } else { + router.push(path); + } + } + }; + + // Toggle between modes + const toggleMode = () => { + setMode((prevMode) => { + const newMode = prevMode === "hierarchical" ? "history" : "hierarchical"; + settings.handleUpdate({ breadcrumbMode: newMode }); + return newMode; + }); + }; + + // Render based on mode + if (mode === "hierarchical") { + let breadcrumbs = buildHierarchicalBreadcrumbs(); + + // Fallback: If no breadcrumbs found in navigation config, generate from URL path + if (breadcrumbs.length === 0) { + const pathSegments = router.pathname.split("/").filter((segment) => segment); + + if (pathSegments.length > 0) { + breadcrumbs = pathSegments.map((segment, index) => { + // Build the path up to this segment + const path = "/" + pathSegments.slice(0, index + 1).join("/"); + + // Format segment as title (replace hyphens with spaces, capitalize words) + const title = segment + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); + + return { + title, + path, + type: "fallback", + query: index === pathSegments.length - 1 ? getCleanQueryParams(router.query) : {}, + }; + }); + + // If we have a current page title from document.title, use it for the last breadcrumb + if ( + currentPageTitle && + currentPageTitle !== "CIPP" && + !currentPageTitle.toLowerCase().includes("loading") + ) { + breadcrumbs[breadcrumbs.length - 1].title = cleanPageTitle(currentPageTitle); + } + } + } + + // Don't show if still no breadcrumbs found + if (breadcrumbs.length === 0) { + return null; + } + + return ( + + + + + + + } + aria-label="page hierarchy" + sx={{ fontSize: "0.875rem", flexGrow: 1 }} + > + {breadcrumbs.map((crumb, index) => { + const isLast = index === breadcrumbs.length - 1; + const pathInfo = getPathInfo(crumb.path); + // Use title from nav/tabs if available, otherwise use the crumb's title + const displayTitle = pathInfo.title || crumb.title; + + // Items without paths (headers/groups) - show as text + if (!crumb.path) { + return ( + + {displayTitle} + + ); + } + + // Items with valid paths are clickable + // Items with invalid paths (fallback) are shown as plain text + if (pathInfo.isValid) { + return ( + handleHierarchicalClick(crumb.path, crumb.query)} + sx={{ + textDecoration: "none", + color: isLast ? "text.primary" : "text.secondary", + fontWeight: isLast ? 500 : 400, + "&:hover": { + textDecoration: "underline", + color: "primary.main", + }, + }} + > + {displayTitle} + + ); + } else { + // Invalid path - show as text only + return ( + + {displayTitle} + + ); + } + })} + + + ); + } + + // Default mode: history-based breadcrumbs + // Don't show breadcrumbs if we have no history + if (history.length === 0) { + return null; + } + + // Show only the last MAX_BREADCRUMB_DISPLAY items + const visibleHistory = history.slice(-MAX_BREADCRUMB_DISPLAY); + + return ( + + + + + + + } + aria-label="navigation history" + sx={{ fontSize: "0.875rem", flexGrow: 1 }} + > + {visibleHistory.map((page, index) => { + const isLast = index === visibleHistory.length - 1; + // Calculate the actual index in the full history + const actualIndex = history.length - visibleHistory.length + index; + + if (isLast) { + return ( + + {page.title} + + ); + } + + return ( + handleBreadcrumbClick(actualIndex)} + sx={{ + textDecoration: "none", + color: "text.secondary", + "&:hover": { + textDecoration: "underline", + color: "primary.main", + }, + }} + > + {page.title} + + ); + })} + + + ); +}; diff --git a/src/components/CippComponents/CippBulkInviteGuestDrawer.jsx b/src/components/CippComponents/CippBulkInviteGuestDrawer.jsx new file mode 100644 index 000000000000..f09d8d7bd1a7 --- /dev/null +++ b/src/components/CippComponents/CippBulkInviteGuestDrawer.jsx @@ -0,0 +1,282 @@ +import { useState } from "react"; +import { Button, Link, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm, useWatch } from "react-hook-form"; +import { GroupAdd, Delete } from "@mui/icons-material"; +import { CippOffCanvas } from "./CippOffCanvas"; +import CippFormComponent from "./CippFormComponent"; +import { CippDataTable } from "../CippTable/CippDataTable"; +import { CippApiResults } from "./CippApiResults"; +import { useSettings } from "../../hooks/use-settings"; +import { ApiPostCall } from "../../api/ApiCall"; + +export const CippBulkInviteGuestDrawer = ({ + buttonText = "Bulk Invite Guests", + requiredPermissions = [], + PermissionButton = Button, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const [addRowDialogOpen, setAddRowDialogOpen] = useState(false); + const initialState = useSettings(); + + const fields = ["displayName", "mail", "redirectUri"]; + + const formControl = useForm({ + mode: "onChange", + defaultValues: { + tenantFilter: initialState.currentTenant, + sendInvite: true, + message: "", + bulkGuests: [], + }, + }); + + const bulkGuestsData = useWatch({ control: formControl.control, name: "bulkGuests" }); + + const inviteGuestsBulk = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: [`Users-${initialState.currentTenant}`], + }); + + formControl.register("bulkGuests", { + validate: (value) => Array.isArray(value) && value.length > 0, + }); + + const handleRemoveItem = (row) => { + if (row === undefined) return false; + const currentData = formControl.getValues("bulkGuests") || []; + // Match Bulk User drawer behavior first (reference equality) + let index = currentData.findIndex((item) => item === row); + + // Fallback: table/CSV layers can change object references + if (index < 0) { + const normalize = (value) => + String(value ?? "") + .trim() + .toLowerCase(); + const targetDisplayName = normalize(row?.displayName); + const targetMail = normalize(row?.mail); + const targetRedirectUri = normalize(row?.redirectUri); + + index = currentData.findIndex((item) => { + return ( + normalize(item?.displayName) === targetDisplayName && + normalize(item?.mail) === targetMail && + normalize(item?.redirectUri) === targetRedirectUri + ); + }); + } + + if (index < 0) return false; + const newData = [...currentData]; + newData.splice(index, 1); + formControl.setValue("bulkGuests", newData, { shouldValidate: true }); + }; + + const handleAddItem = () => { + const newRowData = formControl.getValues("addrow"); + if (!newRowData) return; + + const nextRow = { + displayName: newRowData.displayName ?? "", + mail: newRowData.mail ?? "", + redirectUri: newRowData.redirectUri ?? "", + }; + + if (!nextRow.displayName || !nextRow.mail) { + return; + } + + const currentData = formControl.getValues("bulkGuests") || []; + formControl.setValue("bulkGuests", [...currentData, nextRow], { shouldValidate: true }); + setAddRowDialogOpen(false); + formControl.reset({ + ...formControl.getValues(), + addrow: {}, + }); + }; + + const handleSubmit = () => { + const formData = formControl.getValues(); + const tenantFilter = formData.tenantFilter; + + const payload = (formData.bulkGuests || []).map((row) => ({ + tenantFilter, + displayName: row?.displayName ?? "", + mail: row?.mail ?? "", + redirectUri: row?.redirectUri ?? "", + message: formData.message ?? "", + sendInvite: !!formData.sendInvite, + })); + + inviteGuestsBulk.mutate({ + url: "/api/AddGuest", + bulkRequest: true, + data: payload, + relatedQueryKeys: [`Users-${initialState.currentTenant}`], + }); + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset({ + tenantFilter: initialState.currentTenant, + sendInvite: true, + message: "", + bulkGuests: [], + }); + }; + + const actions = [ + { + icon: , + label: "Delete Row", + confirmText: "Are you sure you want to delete this row?", + customFunction: handleRemoveItem, + noConfirm: true, + }, + ]; + + return ( + <> + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + + + + + } + > + + + + + + + + + + + + Download Example CSV + + + + + + + + + + + + + + + + + + + setAddRowDialogOpen(false)} + maxWidth="sm" + fullWidth + > + Add a new guest + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/src/components/CippComponents/CippBulkUserDrawer.jsx b/src/components/CippComponents/CippBulkUserDrawer.jsx index 7718ae957138..6fd62b106c38 100644 --- a/src/components/CippComponents/CippBulkUserDrawer.jsx +++ b/src/components/CippComponents/CippBulkUserDrawer.jsx @@ -11,7 +11,7 @@ import { CippApiResults } from "./CippApiResults"; import { useSettings } from "../../hooks/use-settings"; import { ApiPostCall } from "../../api/ApiCall"; import { getCippTranslation } from "../../utils/get-cipp-translation"; -import countryList from "/src/data/countryList.json"; +import countryList from "../../data/countryList.json"; export const CippBulkUserDrawer = ({ buttonText = "Bulk Add Users", diff --git a/src/components/CippComponents/CippCADeployDrawer.jsx b/src/components/CippComponents/CippCADeployDrawer.jsx index 419663a2ebd6..6b2a4a633ff4 100644 --- a/src/components/CippComponents/CippCADeployDrawer.jsx +++ b/src/components/CippComponents/CippCADeployDrawer.jsx @@ -9,6 +9,7 @@ import CippJsonView from "../CippFormPages/CippJSONView"; import { CippApiResults } from "./CippApiResults"; import { useSettings } from "../../hooks/use-settings"; import { CippFormTenantSelector } from "./CippFormTenantSelector"; +import { CippFormCondition } from "./CippFormCondition"; export const CippCADeployDrawer = ({ buttonText = "Deploy CA Policy", @@ -24,6 +25,10 @@ export const CippCADeployDrawer = ({ const CATemplates = ApiGetCall({ url: "/api/ListCATemplates", queryKey: "CATemplates" }); const [JSONData, setJSONData] = useState(); const watcher = useWatch({ control: formControl.control, name: "TemplateList" }); + const selectedReplaceMode = useWatch({ + control: formControl.control, + name: "replacename", + }); // Use external open state if provided, otherwise use internal state const drawerVisible = open !== null ? open : internalDrawerVisible; @@ -199,6 +204,25 @@ export const CippCADeployDrawer = ({ label="Disable Security Defaults if enabled when creating policy" formControl={formControl} /> + + + diff --git a/src/components/CippComponents/CippCentralSearch.jsx b/src/components/CippComponents/CippCentralSearch.jsx index e2d31e5bffd4..2f0252fcaf11 100644 --- a/src/components/CippComponents/CippCentralSearch.jsx +++ b/src/components/CippComponents/CippCentralSearch.jsx @@ -14,8 +14,8 @@ import { } from "@mui/material"; import { Grid } from "@mui/system"; import { useRouter } from "next/router"; -import { nativeMenuItems } from "/src/layouts/config"; -import { usePermissions } from "/src/hooks/use-permissions"; +import { nativeMenuItems } from "../../layouts/config"; +import { usePermissions } from "../../hooks/use-permissions"; /** * Recursively collects only leaf items (those without sub-items). @@ -46,7 +46,7 @@ async function loadTabOptions() { "/email/administration/exchange-retention", "/cipp/custom-data", "/cipp/super-admin", - "/tenant/standards/list-standards", + "/tenant/standards", "/tenant/manage", "/tenant/administration/applications", "/tenant/administration/tenants", @@ -62,7 +62,7 @@ async function loadTabOptions() { for (const basePath of tabOptionPaths) { try { - const module = await import(`/src/pages${basePath}/tabOptions.json`); + const module = await import(`../../pages${basePath}/tabOptions.json`); const options = module.default || module; // Add each tab option with metadata @@ -88,14 +88,6 @@ async function loadTabOptions() { */ function filterItemsByPermissionsAndRoles(items, userPermissions, userRoles) { return items.filter((item) => { - // Check roles if specified - if (item.roles && item.roles.length > 0) { - const hasRole = item.roles.some((requiredRole) => userRoles.includes(requiredRole)); - if (!hasRole) { - return false; - } - } - // Check permissions with pattern matching support if (item.permissions && item.permissions.length > 0) { const hasPermission = userPermissions?.some((userPerm) => { @@ -109,6 +101,7 @@ function filterItemsByPermissionsAndRoles(items, userPermissions, userRoles) { if (requiredPerm.includes("*")) { // Convert wildcard pattern to regex const regexPattern = requiredPerm + .replace(/\\/g, "\\\\") // Escape backslashes .replace(/\./g, "\\.") // Escape dots .replace(/\*/g, ".*"); // Convert * to .* const regex = new RegExp(`^${regexPattern}$`); @@ -179,7 +172,7 @@ export const CippCentralSearch = ({ handleClose, open }) => { const filteredMainMenu = filterItemsByPermissionsAndRoles( allLeafItems, userPermissions, - userRoles + userRoles, ).map((item) => { const rawBreadcrumbs = buildBreadcrumbPath(nativeMenuItems, item.path) || []; // Remove the leaf item's own title to avoid duplicate when rendering @@ -257,7 +250,7 @@ export const CippCentralSearch = ({ handleClose, open }) => { const inTitle = leaf.title?.toLowerCase().includes(normalizedSearch); const inPath = leaf.path?.toLowerCase().includes(normalizedSearch); const inBreadcrumbs = leaf.breadcrumbs?.some((crumb) => - crumb?.toLowerCase().includes(normalizedSearch) + crumb?.toLowerCase().includes(normalizedSearch), ); // If there's no search value, show no results (you could change this logic) return normalizedSearch ? inTitle || inPath || inBreadcrumbs : false; @@ -274,7 +267,7 @@ export const CippCentralSearch = ({ handleClose, open }) => { ) : ( part - ) + ), ); }; diff --git a/src/components/CippComponents/CippCustomVariables.jsx b/src/components/CippComponents/CippCustomVariables.jsx index b408deaae8ea..69b5975d1777 100644 --- a/src/components/CippComponents/CippCustomVariables.jsx +++ b/src/components/CippComponents/CippCustomVariables.jsx @@ -1,10 +1,10 @@ import { useState } from "react"; import { CardContent, Button, SvgIcon, Alert } from "@mui/material"; import { PlusIcon, TrashIcon, PencilIcon } from "@heroicons/react/24/outline"; -import { CippDataTable } from "/src/components/CippTable/CippDataTable"; -import { CippApiResults } from "/src/components/CippComponents/CippApiResults"; -import { CippApiDialog } from "/src/components/CippComponents/CippApiDialog"; -import { ApiPostCall } from "/src/api/ApiCall"; +import { CippDataTable } from "../CippTable/CippDataTable"; +import { CippApiResults } from "./CippApiResults"; +import { CippApiDialog } from "./CippApiDialog"; +import { ApiPostCall } from "../../api/ApiCall"; const CippCustomVariables = ({ id }) => { const [openAddDialog, setOpenAddDialog] = useState(false); diff --git a/src/components/CippComponents/CippDomainServiceConfigurationRecords.jsx b/src/components/CippComponents/CippDomainServiceConfigurationRecords.jsx new file mode 100644 index 000000000000..a6a75fd2d4f3 --- /dev/null +++ b/src/components/CippComponents/CippDomainServiceConfigurationRecords.jsx @@ -0,0 +1,134 @@ +import React, { useState } from "react"; +import { useSettings } from "../../hooks/use-settings"; +import { ApiGetCall } from "../../api/ApiCall"; +import { + Card, + CardContent, + CardHeader, + Stack, + Box, + IconButton, + Tooltip, + Typography, + Chip, + CircularProgress, +} from "@mui/material"; +import { ContentCopy, Check } from "@mui/icons-material"; + +const DnsRecordField = ({ label, value, copyable = true }) => { + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + + + + {label} + + + {value} + + + {copyable && ( + + + {copied ? : } + + + )} + + ); +}; + +const renderRecordDetails = (record) => { + switch (record.recordType) { + case "Mx": + return ( + <> + + + + ); + case "Txt": + return ; + case "CName": + return ; + case "Srv": + return ( + <> + + + + + + + + ); + default: + return null; + } +}; + +export const CippDomainServiceConfigurationRecords = ({ row }) => { + const tenantFilter = useSettings().currentTenant; + + const recordsQuery = ApiGetCall({ + url: "/api/ListGraphRequest", + queryKey: `domain-service-config-${row.id}`, + waiting: true, + data: { + Endpoint: `domains/${row.id}/serviceConfigurationRecords`, + tenantFilter: tenantFilter, + }, + }); + + if (recordsQuery.isLoading) { + return ( + + + + ); + } + + if (recordsQuery.isError) { + return Failed to load records; + } + + const records = recordsQuery.data?.Results || []; + + if (records.length === 0) { + return No service configuration records found; + } + + return ( + + {records.map((record) => ( + + + {record.label} + + + + } + subheader={`TTL: ${record.ttl} | Optional: ${record.isOptional ? "Yes" : "No"}`} + sx={{ pb: 1 }} + /> + + {renderRecordDetails(record)} + + + ))} + + ); +}; diff --git a/src/components/CippComponents/CippDomainVerificationRecords.jsx b/src/components/CippComponents/CippDomainVerificationRecords.jsx new file mode 100644 index 000000000000..2a8a741cd264 --- /dev/null +++ b/src/components/CippComponents/CippDomainVerificationRecords.jsx @@ -0,0 +1,133 @@ +import React, { useState } from "react"; +import { useSettings } from "../../hooks/use-settings"; +import { ApiGetCall } from "../../api/ApiCall"; +import { + Card, + CardContent, + CardHeader, + Stack, + Box, + IconButton, + Tooltip, + Typography, + Chip, + CircularProgress, +} from "@mui/material"; +import { ContentCopy, Check } from "@mui/icons-material"; + +const DnsRecordField = ({ label, value, copyable = true }) => { + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + + + + {label} + + + {value} + + + {copyable && ( + + + {copied ? : } + + + )} + + ); +}; + +const renderRecordDetails = (record) => { + switch (record.recordType) { + case "Txt": + return ; + case "Mx": + return ( + <> + + + + ); + case "CName": + return ; + case "Srv": + return ( + <> + + + + + + + + ); + default: + return null; + } +}; + +export const CippDomainVerificationRecords = ({ row }) => { + const tenantFilter = useSettings().currentTenant; + + const recordsQuery = ApiGetCall({ + url: "/api/ListGraphRequest", + queryKey: `domain-verification-${row.id}`, + waiting: true, + data: { + Endpoint: `domains/${row.id}/verificationDnsRecords`, + tenantFilter: tenantFilter, + }, + }); + + if (recordsQuery.isLoading) { + return ( + + + + ); + } + + if (recordsQuery.isError) { + return Failed to load records; + } + + const records = recordsQuery.data?.Results || []; + + if (records.length === 0) { + return No verification records found; + } + + return ( + + {records.map((record) => ( + + + {record.label} + + + } + subheader={`TTL: ${record.ttl} | Optional: ${record.isOptional ? "Yes" : "No"}`} + sx={{ pb: 1 }} + /> + + {renderRecordDetails(record)} + + + ))} + + ); +}; diff --git a/src/components/CippComponents/CippExchangeActions.jsx b/src/components/CippComponents/CippExchangeActions.jsx index 1a4be9744d55..a783b263a339 100644 --- a/src/components/CippComponents/CippExchangeActions.jsx +++ b/src/components/CippComponents/CippExchangeActions.jsx @@ -17,7 +17,7 @@ import { PersonAdd, Email, } from "@mui/icons-material"; -import { useSettings } from "/src/hooks/use-settings.js"; +import { useSettings } from "../../hooks/use-settings.js"; import { useMemo } from "react"; export const CippExchangeActions = () => { @@ -301,12 +301,14 @@ export const CippExchangeActions = () => { label: "Set Copy Sent Items for Delegated Mailboxes", type: "POST", icon: , + condition: (row) => + row.recipientTypeDetails === "UserMailbox" || row.recipientTypeDetails === "SharedMailbox", url: "/api/ExecCopyForSent", data: { ID: "UPN" }, fields: [ { type: "radio", - name: "MessageCopyForSentAsEnabled", + name: "messageCopyState", label: "Copy Sent Items", options: [ { label: "Enabled", value: true }, diff --git a/src/components/CippComponents/CippFormComponent.jsx b/src/components/CippComponents/CippFormComponent.jsx index 4aff78c756f0..e96cefc9b395 100644 --- a/src/components/CippComponents/CippFormComponent.jsx +++ b/src/components/CippComponents/CippFormComponent.jsx @@ -11,6 +11,7 @@ import { Button, Box, Input, + Tooltip, } from "@mui/material"; import { CippAutoComplete } from "./CippAutocomplete"; import { CippTextFieldWithVariables } from "./CippTextFieldWithVariables"; @@ -55,6 +56,7 @@ export const CippFormComponent = (props) => { helperText, disableVariables = false, includeSystemVariables = false, + row, ...other } = props; const { errors } = useFormState({ control: formControl.control }); @@ -121,23 +123,78 @@ export const CippFormComponent = (props) => { )} /> - - {get(errors, convertedName, {})?.message} - + {get(errors, convertedName, {})?.message && ( + + {get(errors, convertedName, {})?.message} + + )} ); case "textField": return ( <> -
- - !disableVariables ? ( + +
+ + !disableVariables ? ( + + ) : ( + + ) + } + /> +
+
+ {get(errors, convertedName, {})?.message && ( + + {get(errors, convertedName, {})?.message} + + )} + {helperText && ( + + {helperText} + + )} + + ); + case "textFieldWithVariables": + return ( + <> + +
+ ( { label={label} value={field.value || ""} onChange={field.onChange} + tenantFilter={tenantFilter} includeSystemVariables={includeSystemVariables} /> - ) : ( - - ) - } - /> -
- - {get(errors, convertedName, {})?.message} - - {helperText && ( - - {helperText} + )} + /> +
+ + {get(errors, convertedName, {})?.message && ( + + {get(errors, convertedName, {})?.message} )} - - ); - case "textFieldWithVariables": - return ( - <> -
- ( - - )} - /> -
- - {get(errors, convertedName, {})?.message} - {helperText && ( {helperText} @@ -216,22 +228,26 @@ export const CippFormComponent = (props) => { return ( <>
- + + +
- - {get(errors, convertedName, {})?.message} - + {get(errors, convertedName, {})?.message && ( + + {get(errors, convertedName, {})?.message} + + )} {helperText && ( {helperText} @@ -243,21 +259,25 @@ export const CippFormComponent = (props) => { return ( <>
- + + +
- - {get(errors, convertedName, {})?.message} - + {get(errors, convertedName, {})?.message && ( + + {get(errors, convertedName, {})?.message} + + )} {helperText && ( {helperText} @@ -280,14 +300,16 @@ export const CippFormComponent = (props) => { checked={Boolean(field.value)} {...other} {...formControl.register(convertedName, { ...validators })} - /> + />, ) } /> - - {get(errors, convertedName, {})?.message} - + {get(errors, convertedName, {})?.message && ( + + {get(errors, convertedName, {})?.message} + + )} {helperText && ( {helperText} @@ -303,9 +325,11 @@ export const CippFormComponent = (props) => { - - {get(errors, convertedName, {})?.message} - + {get(errors, convertedName, {})?.message && ( + + {get(errors, convertedName, {})?.message} + + )} ); @@ -331,6 +355,7 @@ export const CippFormComponent = (props) => { render={({ field }) => { return ( field.onChange(e.target.value)} {...other} @@ -348,9 +373,11 @@ export const CippFormComponent = (props) => { }} /> - - {get(errors, convertedName, {})?.message} - + {get(errors, convertedName, {})?.message && ( + + {get(errors, convertedName, {})?.message} + + )} ); @@ -383,16 +410,32 @@ export const CippFormComponent = (props) => { ); - case "autoComplete": + 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; + return (
( { )}
); + } case "richText": { const editorInstanceRef = React.useRef(null); @@ -492,7 +536,7 @@ export const CippFormComponent = (props) => { acc[csvHeader] = internalKey; return acc; }, - {} + {}, ); return data.map((row) => { @@ -682,9 +726,11 @@ export const CippFormComponent = (props) => { )} /> - - {get(errors, convertedName, {})?.message} - + {get(errors, convertedName, {})?.message && ( + + {get(errors, convertedName, {})?.message} + + )} {helperText && ( {helperText} diff --git a/src/components/CippComponents/CippFormCondition.jsx b/src/components/CippComponents/CippFormCondition.jsx index 9ec49a57ef5c..dd9a48cbf95d 100644 --- a/src/components/CippComponents/CippFormCondition.jsx +++ b/src/components/CippComponents/CippFormCondition.jsx @@ -18,7 +18,7 @@ export const CippFormCondition = (props) => { if ( field === undefined || - compareValue === undefined || + (compareValue === undefined && compareType !== "hasValue") || children === undefined || formControl === undefined ) { @@ -148,10 +148,18 @@ export const CippFormCondition = (props) => { watcher.length >= compareValue ); case "hasValue": - return ( - (watcher !== undefined && watcher !== null && watcher !== "") || - (watcher?.value !== undefined && watcher?.value !== null && watcher?.value !== "") - ); + // Check watchedValue (the extracted value based on propertyName) + // For simple values (strings, numbers) + if (watchedValue === undefined || watchedValue === null || watchedValue === "") { + return false; + } + // If it's an array, check if it has elements + if (Array.isArray(watchedValue)) { + return watchedValue.length > 0; + } + console.log("watched value:", watchedValue); + // For any other truthy value (objects, numbers, strings), consider it as having a value + return true; case "labelEq": return Array.isArray(watcher) && watcher.some((item) => item?.label === compareValue); case "labelContains": diff --git a/src/components/CippComponents/CippFormDomainSelector.jsx b/src/components/CippComponents/CippFormDomainSelector.jsx index 8d9cbea7dca2..71b6e9753455 100644 --- a/src/components/CippComponents/CippFormDomainSelector.jsx +++ b/src/components/CippComponents/CippFormDomainSelector.jsx @@ -38,11 +38,13 @@ export const CippFormDomainSelector = ({ }, dataFilter: (domains) => { // Always sort domains so that the default domain appears first - return domains.sort((a, b) => { - if (a.addedFields?.isDefault === true) return -1; - if (b.addedFields?.isDefault === true) return 1; - return 0; - }); + return domains + .filter((domain) => domain?.addedFields?.isVerified === true) + .sort((a, b) => { + if (a.addedFields?.isDefault === true) return -1; + if (b.addedFields?.isDefault === true) return 1; + return 0; + }); }, }), [currentTenant, selectedTenant, preselectDefaultDomain, multiple] diff --git a/src/components/CippComponents/CippFormTenantSelector.jsx b/src/components/CippComponents/CippFormTenantSelector.jsx index 0ce271515d29..4f39e48868d4 100644 --- a/src/components/CippComponents/CippFormTenantSelector.jsx +++ b/src/components/CippComponents/CippFormTenantSelector.jsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { CippFormComponent } from "./CippFormComponent"; import { useSettings } from "../../hooks/use-settings"; import { GroupHeader, GroupItems } from "../CippComponents/CippAutocompleteGrouping"; -import { ApiGetCall } from "/src/api/ApiCall"; +import { ApiGetCall } from "../../api/ApiCall"; export const CippFormTenantSelector = ({ formControl, diff --git a/src/components/CippComponents/CippForwardingSection.jsx b/src/components/CippComponents/CippForwardingSection.jsx index df7fcba9b177..24a18345ddc5 100644 --- a/src/components/CippComponents/CippForwardingSection.jsx +++ b/src/components/CippComponents/CippForwardingSection.jsx @@ -3,7 +3,7 @@ import CippFormComponent from "./CippFormComponent"; import { CippFormCondition } from "./CippFormCondition"; import { Grid } from "@mui/system"; import { CippApiResults } from "./CippApiResults"; -import { getCippValidator } from "/src/utils/get-cipp-validator"; +import { getCippValidator } from "../../utils/get-cipp-validator"; const CippForwardingSection = ({ formControl, usersList, contactsList, postRequest, handleSubmit }) => { diff --git a/src/components/CippComponents/CippGeoLocation.jsx b/src/components/CippComponents/CippGeoLocation.jsx index e7a4be63ed66..1621f602ba1d 100644 --- a/src/components/CippComponents/CippGeoLocation.jsx +++ b/src/components/CippComponents/CippGeoLocation.jsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { Skeleton } from "@mui/material"; import { Grid } from "@mui/system"; import dynamic from "next/dynamic"; -import { ApiPostCall } from "/src/api/ApiCall"; +import { ApiPostCall } from "../../api/ApiCall"; import { CippPropertyList } from "./CippPropertyList"; import { getCippTranslation } from "../../utils/get-cipp-translation"; import { getCippFormatting } from "../../utils/get-cipp-formatting"; diff --git a/src/components/CippComponents/CippInviteGuestDrawer.jsx b/src/components/CippComponents/CippInviteGuestDrawer.jsx index 58ecf59a4a83..5c5a62a31b58 100644 --- a/src/components/CippComponents/CippInviteGuestDrawer.jsx +++ b/src/components/CippComponents/CippInviteGuestDrawer.jsx @@ -24,7 +24,8 @@ export const CippInviteGuestDrawer = ({ displayName: "", mail: "", redirectUri: "", - sendInvite: false, + message: "", + sendInvite: true, }, }); @@ -63,7 +64,8 @@ export const CippInviteGuestDrawer = ({ displayName: "", mail: "", redirectUri: "", - sendInvite: false, + message: "", + sendInvite: true, }); }; @@ -92,8 +94,8 @@ export const CippInviteGuestDrawer = ({ {inviteGuest.isLoading ? "Sending Invite..." : inviteGuest.isSuccess - ? "Send Another Invite" - : "Send Invite"} + ? "Send Another Invite" + : "Send Invite"}
@@ -179,6 +188,7 @@ export const CippNotificationForm = ({ text: "This is a test from Notification Settings", }), }} + allowResubmit={true} /> )} diff --git a/src/components/CippComponents/CippOffCanvas.jsx b/src/components/CippComponents/CippOffCanvas.jsx index 25b05ed69a28..b8e5b548e94d 100644 --- a/src/components/CippComponents/CippOffCanvas.jsx +++ b/src/components/CippComponents/CippOffCanvas.jsx @@ -4,6 +4,8 @@ import { getCippTranslation } from "../../utils/get-cipp-translation"; import { getCippFormatting } from "../../utils/get-cipp-formatting"; import { useMediaQuery, Grid } from "@mui/system"; import CloseIcon from "@mui/icons-material/Close"; +import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; export const CippOffCanvas = (props) => { const { @@ -17,6 +19,10 @@ export const CippOffCanvas = (props) => { children, size = "sm", footer, + onNavigateUp, + onNavigateDown, + canNavigateUp = false, + canNavigateDown = false, } = props; const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md")); @@ -84,9 +90,31 @@ export const CippOffCanvas = (props) => { sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", p: 1.5 }} > {title} - - - + + {(canNavigateUp || canNavigateDown) && ( + <> + + + + + + + + )} + + + + { const match = option.BackupName.match(/.*_(\d{4}-\d{2}-\d{2})-(\d{2})(\d{2})/); - return match ? `${match[1]} @ ${match[2]}:${match[3]}` : option.BackupName; + const dateTime = match + ? `${match[1]} @ ${match[2]}:${match[3]}` + : option.BackupName; + const tenantDisplay = + tenantFilter === "AllTenants" ? ` (${option.TenantFilter})` : ""; + return `${dateTime}${tenantDisplay}`; }, valueField: "BackupName", data: { diff --git a/src/components/CippComponents/CippSankey.jsx b/src/components/CippComponents/CippSankey.jsx new file mode 100644 index 000000000000..eb583b801ac4 --- /dev/null +++ b/src/components/CippComponents/CippSankey.jsx @@ -0,0 +1,75 @@ +import { ResponsiveSankey } from "@nivo/sankey"; +import { useSettings } from "../../hooks/use-settings"; + +export const CippSankey = ({ data, onNodeClick, onLinkClick }) => { + const settings = useSettings(); + const isDark = settings.currentTheme?.value === "dark"; + + const theme = { + tooltip: { + container: { + background: isDark ? "rgba(33, 33, 33, 0.95)" : "rgba(255, 255, 255, 0.95)", + color: isDark ? "#ffffff" : "#000000", + border: isDark ? "1px solid #555" : "1px solid #ccc", + borderRadius: "4px", + boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)", + fontSize: "12px", + padding: "8px 12px", + }, + }, + labels: { + text: { + fontSize: 12, + }, + }, + }; + + return ( +
+ node.nodeColor} + nodeOpacity={1} + nodeHoverOthersOpacity={0.35} + nodeThickness={18} + nodeSpacing={24} + nodeBorderWidth={0} + nodeBorderColor={{ + from: "color", + modifiers: [["darker", 0.8]], + }} + nodeBorderRadius={3} + linkOpacity={0.5} + linkHoverOthersOpacity={0.1} + linkContract={3} + linkBlendMode={isDark ? "lighten" : "multiply"} + enableLinkGradient={true} + labelPosition="inside" + labelOrientation="horizontal" + labelPadding={16} + labelTextColor={isDark ? "#ffffff" : "#000000"} + sort="input" + legends={[]} + valueFormat={(value) => `${value}`} + isInteractive={true} + onClick={(node, event) => { + if (onNodeClick && node.id) { + onNodeClick(node); + } else if (onLinkClick && node.source) { + onLinkClick(node); + } + }} + /> +
+ ); +}; diff --git a/src/components/CippComponents/CippTablePage.jsx b/src/components/CippComponents/CippTablePage.jsx index 5ab4223b933d..b95db81b5a0c 100644 --- a/src/components/CippComponents/CippTablePage.jsx +++ b/src/components/CippComponents/CippTablePage.jsx @@ -3,7 +3,7 @@ import { Box, Container, Stack } from "@mui/system"; import { CippDataTable } from "../CippTable/CippDataTable"; import { useSettings } from "../../hooks/use-settings"; import { CippHead } from "./CippHead"; -import { useState } from "react"; +import { useState, useEffect } from "react"; export const CippTablePage = (props) => { const { @@ -24,11 +24,14 @@ export const CippTablePage = (props) => { tableFilter, tenantInTitle = true, filters, - sx = { flexGrow: 1, py: 4 }, + initialFilters, + sx = {}, ...other } = props; const tenant = useSettings().currentTenant; - const [tableFilters] = useState(filters || []); + + // Use initialFilters if provided, otherwise use regular filters + const activeFilters = initialFilters || filters; return ( <> @@ -63,13 +66,7 @@ export const CippTablePage = (props) => { columns={columns} columnsFromApi={columnsFromApi} offCanvas={offCanvas} - filters={tableFilters} - initialState={{ - columnFilters: filters ? filters.map(filter => ({ - id: filter.id || filter.columnId, - value: filter.value - })) : [] - }} + filters={activeFilters} {...other} /> diff --git a/src/components/CippComponents/CippTemplateEditor.jsx b/src/components/CippComponents/CippTemplateEditor.jsx index e223abf49962..bb2c38af8b53 100644 --- a/src/components/CippComponents/CippTemplateEditor.jsx +++ b/src/components/CippComponents/CippTemplateEditor.jsx @@ -2,11 +2,11 @@ import React, { useEffect, useState } from "react"; import { Box, Typography, Divider } from "@mui/material"; import { Grid } from "@mui/system"; import { useForm } from "react-hook-form"; -import CippFormPage from "/src/components/CippFormPages/CippFormPage"; -import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; -import CippFormSkeleton from "/src/components/CippFormPages/CippFormSkeleton"; -import { ApiGetCall } from "/src/api/ApiCall"; -import { getCippTranslation } from "/src/utils/get-cipp-translation"; +import CippFormPage from "../CippFormPages/CippFormPage"; +import CippFormComponent from "./CippFormComponent"; +import CippFormSkeleton from "../CippFormPages/CippFormSkeleton"; +import { ApiGetCall } from "../../api/ApiCall"; +import { getCippTranslation } from "../../utils/get-cipp-translation"; const CippTemplateEditor = ({ templateId, diff --git a/src/components/CippComponents/CippTemplateFieldRenderer.jsx b/src/components/CippComponents/CippTemplateFieldRenderer.jsx index 7fae93ec413f..5f385bfdda64 100644 --- a/src/components/CippComponents/CippTemplateFieldRenderer.jsx +++ b/src/components/CippComponents/CippTemplateFieldRenderer.jsx @@ -1,9 +1,9 @@ import React from "react"; import { Typography, Divider } from "@mui/material"; import { Grid } from "@mui/system"; -import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; -import { getCippTranslation } from "/src/utils/get-cipp-translation"; -import intuneCollection from "/src/data/intuneCollection.json"; +import CippFormComponent from "./CippFormComponent"; +import { getCippTranslation } from "../../utils/get-cipp-translation"; +import intuneCollection from "../../data/intuneCollection.json"; const CippTemplateFieldRenderer = ({ templateData, diff --git a/src/components/CippComponents/CippTenantSelector.jsx b/src/components/CippComponents/CippTenantSelector.jsx index 36dc655be6be..ab9e50ea8fcf 100644 --- a/src/components/CippComponents/CippTenantSelector.jsx +++ b/src/components/CippComponents/CippTenantSelector.jsx @@ -19,18 +19,22 @@ import { ServerIcon, UsersIcon, } from "@heroicons/react/24/outline"; -import { useEffect, useState, useMemo } from "react"; +import { useEffect, useState, useMemo, useCallback, useRef } from "react"; import { useRouter } from "next/router"; import { CippOffCanvas } from "./CippOffCanvas"; import { useSettings } from "../../hooks/use-settings"; import { getCippError } from "../../utils/get-cipp-error"; +import { useQueryClient } from "@tanstack/react-query"; export const CippTenantSelector = (props) => { const { width, allTenants = false, multiple = false, refreshButton, tenantButton } = props; //get the current tenant from SearchParams called 'tenantFilter' const router = useRouter(); const settings = useSettings(); + const queryClient = useQueryClient(); const tenant = router.query.tenantFilter ? router.query.tenantFilter : settings.currentTenant; + const routerUpdateTimeoutRef = useRef(null); + // Fetch tenant list const tenantList = ApiGetCall({ url: "/api/listTenants", @@ -172,6 +176,15 @@ export const CippTenantSelector = (props) => { if (currentTenant?.value) { const query = { ...router.query }; if (query.tenantFilter !== currentTenant.value) { + // Clear any pending timeout + if (routerUpdateTimeoutRef.current) { + clearTimeout(routerUpdateTimeoutRef.current); + } + + // Cancel all in-flight queries before changing tenant + queryClient.cancelQueries(); + + // Update router only - let the URL watcher handle settings query.tenantFilter = currentTenant.value; router.replace( { @@ -182,41 +195,47 @@ export const CippTenantSelector = (props) => { { shallow: true } ); } - settings.handleUpdate({ - currentTenant: currentTenant.value, - }); - //if we have a tenantfilter, we add the tenantfilter to the title of the tab/page so its "Tenant - original title". } }, [currentTenant?.value]); - // This effect handles when the URL parameter changes externally + // This effect handles when the URL parameter changes (from deep link or user selection) + // This is the single source of truth for tenant changes useEffect(() => { if (!router.isReady || !tenantList.isSuccess) return; - // Get the current tenant from URL or settings - const urlTenant = router.query.tenantFilter || settings.currentTenant; + const urlTenant = router.query.tenantFilter; - // Only update if there's a URL tenant and it's different from our current state - if (urlTenant && (!currentTenant || urlTenant !== currentTenant.value)) { + // Only process if we have a URL tenant + if (urlTenant) { // Find the tenant in our list const matchingTenant = tenantList.data.find( ({ defaultDomainName }) => defaultDomainName === urlTenant ); if (matchingTenant) { - setSelectedTenant({ - value: urlTenant, - label: `${matchingTenant.displayName} (${urlTenant})`, - addedFields: { - defaultDomainName: matchingTenant.defaultDomainName, - displayName: matchingTenant.displayName, - customerId: matchingTenant.customerId, - initialDomainName: matchingTenant.initialDomainName, - }, - }); + // Update local state if different + if (!currentTenant || urlTenant !== currentTenant.value) { + setSelectedTenant({ + value: urlTenant, + label: `${matchingTenant.displayName} (${urlTenant})`, + addedFields: { + defaultDomainName: matchingTenant.defaultDomainName, + displayName: matchingTenant.displayName, + customerId: matchingTenant.customerId, + initialDomainName: matchingTenant.initialDomainName, + }, + }); + } + + // Update settings if different (null filter in settings-context prevents saving null) + if (settings.currentTenant !== urlTenant) { + settings.handleUpdate({ + currentTenant: urlTenant, + }); + } } } - }, [router.isReady, router.query.tenantFilter, tenantList.isSuccess, settings.currentTenant]); + }, [router.isReady, router.query.tenantFilter, tenantList.isSuccess]); // This effect ensures the tenant filter parameter is included in the URL when missing useEffect(() => { @@ -234,7 +253,7 @@ export const CippTenantSelector = (props) => { { shallow: true } ); } - }, [router.isReady, router.query, settings.currentTenant]); + }, [router.isReady, router.query.tenantFilter, settings.currentTenant]); useEffect(() => { if (tenant && currentTenant?.value && currentTenant?.value !== "AllTenants") { @@ -268,6 +287,15 @@ export const CippTenantSelector = (props) => { } }, [tenant, tenantList.isSuccess, currentTenant]); + // Cleanup on unmount + useEffect(() => { + return () => { + if (routerUpdateTimeoutRef.current) { + clearTimeout(routerUpdateTimeoutRef.current); + } + }; + }, []); + return ( <> { onChange={(nv) => setSelectedTenant(nv)} options={ tenantList.isSuccess && tenantList.data && tenantList.data.length > 0 - ? tenantList.data.map(({ customerId, displayName, defaultDomainName }) => ({ + ? tenantList.data.map(({ customerId, displayName, defaultDomainName, initialDomainName }) => ({ value: defaultDomainName, label: `${displayName} (${defaultDomainName})`, - addedField: { - defaultDomainName: "defaultDomainName", - displayName: "displayName", - customerId: "customerId", + addedFields: { + defaultDomainName: defaultDomainName, + displayName: displayName, + customerId: customerId, + initialDomainName: initialDomainName, }, })) : [] diff --git a/src/components/CippComponents/CippTextFieldWithVariables.jsx b/src/components/CippComponents/CippTextFieldWithVariables.jsx index 0f6abd9fea99..80d720ac9440 100644 --- a/src/components/CippComponents/CippTextFieldWithVariables.jsx +++ b/src/components/CippComponents/CippTextFieldWithVariables.jsx @@ -1,7 +1,7 @@ import React, { useState, useRef, useEffect, useMemo, useCallback } from "react"; import { TextField } from "@mui/material"; import { CippVariableAutocomplete } from "./CippVariableAutocomplete"; -import { useSettings } from "/src/hooks/use-settings.js"; +import { useSettings } from "../../hooks/use-settings.js"; /** * Enhanced TextField that supports custom variable autocomplete diff --git a/src/components/CippComponents/CippTranslations.jsx b/src/components/CippComponents/CippTranslations.jsx index 99d46a6e5182..af96b19cd027 100644 --- a/src/components/CippComponents/CippTranslations.jsx +++ b/src/components/CippComponents/CippTranslations.jsx @@ -52,4 +52,11 @@ export const CippTranslations = { includeTenantId: "Include Tenant ID in Notifications", logsToInclude: "Logs to Include in notifications", assignmentFilterManagementType: "Filter Type", + microsoftSupport: "Microsoft Support", + syndicatePartner: "Syndicate Partner", + breadthPartner: "Breadth Partner", + breadthPartnerDelegatedAdmin: "Breadth Partner (Delegated)", + resellerPartnerDelegatedAdmin: "Direct Reseller", + valueAddedResellerPartnerDelegatedAdmin: "Indirect Reseller", + unknownFutureValue: "Unknown", }; diff --git a/src/components/CippComponents/CippTransportRuleDrawer.jsx b/src/components/CippComponents/CippTransportRuleDrawer.jsx index 8cac4e3fd390..494ae0fdce1a 100644 --- a/src/components/CippComponents/CippTransportRuleDrawer.jsx +++ b/src/components/CippComponents/CippTransportRuleDrawer.jsx @@ -7,8 +7,8 @@ import { CippOffCanvas } from "./CippOffCanvas"; import CippFormComponent from "./CippFormComponent"; import { CippFormDomainSelector } from "./CippFormDomainSelector"; import { CippApiResults } from "./CippApiResults"; -import { useSettings } from "/src/hooks/use-settings"; -import { ApiGetCall, ApiPostCall } from "/src/api/ApiCall"; +import { useSettings } from "../../hooks/use-settings"; +import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; import { useQueryClient } from "@tanstack/react-query"; export const CippTransportRuleDrawer = ({ @@ -104,8 +104,10 @@ export const CippTransportRuleDrawer = ({ const conditionFieldMap = { From: "The sender is...", FromScope: "The sender is located...", + FromMemberOf: "The sender is a member of...", SentTo: "The recipient is...", SentToScope: "The recipient is located...", + SentToMemberOf: "The recipient is a member of...", SubjectContainsWords: "Subject contains words...", SubjectMatchesPatterns: "Subject matches patterns...", SubjectOrBodyContainsWords: "Subject or body contains words...", @@ -122,8 +124,19 @@ export const CippTransportRuleDrawer = ({ MessageTypeMatches: "Message type is...", SenderDomainIs: "Sender domain is...", RecipientDomainIs: "Recipient domain is...", + SenderIpRanges: "Sender IP address belongs to any of these ranges...", HeaderContainsWords: "Message header contains words...", HeaderMatchesPatterns: "Message header matches patterns...", + AnyOfToHeader: "Any To header contains...", + AnyOfToHeaderMemberOf: "Any To header is a member of...", + AnyOfCcHeader: "Any Cc header contains...", + AnyOfCcHeaderMemberOf: "Any Cc header is a member of...", + AnyOfToCcHeader: "Any To or Cc header contains...", + AnyOfToCcHeaderMemberOf: "Any To or Cc header is a member of...", + RecipientAddressContainsWords: "Recipient address contains words...", + RecipientAddressMatchesPatterns: "Recipient address matches patterns...", + AnyOfRecipientAddressContainsWords: "Any recipient address contains words...", + AnyOfRecipientAddressMatchesPatterns: "Any recipient address matches patterns...", }; const actionFieldMap = { @@ -232,6 +245,26 @@ export const CippTransportRuleDrawer = ({ formData[field] = rule[field] !== null ? { value: rule[field].toString(), label: rule[field].toString() } : undefined; + } else if (field === "SenderIpRanges") { + // Transform array of IP strings to autocomplete format + if (Array.isArray(rule[field])) { + formData[field] = rule[field].map(ip => ({ value: ip, label: ip })); + } else { + formData[field] = rule[field]; + } + } else if ( + // Fields that use creatable autocomplete with API (users/groups) + field === "From" || field === "SentTo" || + field === "AnyOfToHeader" || field === "AnyOfCcHeader" || field === "AnyOfToCcHeader" || + field === "FromMemberOf" || field === "SentToMemberOf" || + field === "AnyOfToHeaderMemberOf" || field === "AnyOfCcHeaderMemberOf" || field === "AnyOfToCcHeaderMemberOf" + ) { + // Transform array of email/UPN strings to autocomplete format + if (Array.isArray(rule[field])) { + formData[field] = rule[field].map(item => ({ value: item, label: item })); + } else { + formData[field] = rule[field]; + } } else { formData[field] = rule[field]; } @@ -287,6 +320,26 @@ export const CippTransportRuleDrawer = ({ formData[exceptionField] = rule[exceptionField] !== null ? { value: rule[exceptionField].toString(), label: rule[exceptionField].toString() } : undefined; + } else if (field === "SenderIpRanges") { + // Transform array of IP strings to autocomplete format + if (Array.isArray(rule[exceptionField])) { + formData[exceptionField] = rule[exceptionField].map(ip => ({ value: ip, label: ip })); + } else { + formData[exceptionField] = rule[exceptionField]; + } + } else if ( + // Fields that use creatable autocomplete with API (users/groups) + field === "From" || field === "SentTo" || + field === "AnyOfToHeader" || field === "AnyOfCcHeader" || field === "AnyOfToCcHeader" || + field === "FromMemberOf" || field === "SentToMemberOf" || + field === "AnyOfToHeaderMemberOf" || field === "AnyOfCcHeaderMemberOf" || field === "AnyOfToCcHeaderMemberOf" + ) { + // Transform array of email/UPN strings to autocomplete format + if (Array.isArray(rule[exceptionField])) { + formData[exceptionField] = rule[exceptionField].map(item => ({ value: item, label: item })); + } else { + formData[exceptionField] = rule[exceptionField]; + } } else { formData[exceptionField] = rule[exceptionField]; } @@ -345,9 +398,22 @@ export const CippTransportRuleDrawer = ({ const conditionValue = condition.value || condition; if (values[conditionValue] !== undefined) { const fieldValue = values[conditionValue]; - if (fieldValue && typeof fieldValue === 'object' && fieldValue.value !== undefined) { + + // Handle single object with value property + if (fieldValue && typeof fieldValue === 'object' && !Array.isArray(fieldValue) && fieldValue.value !== undefined) { apiData[conditionValue] = fieldValue.value; - } else { + } + // Handle array of objects with value property (for creatable autocomplete fields) + else if (Array.isArray(fieldValue)) { + apiData[conditionValue] = fieldValue.map(item => { + if (item && typeof item === 'object' && item.value !== undefined) { + return item.value; + } + return item; + }); + } + // Handle plain values + else { apiData[conditionValue] = fieldValue; } } @@ -389,9 +455,22 @@ export const CippTransportRuleDrawer = ({ } } else if (values[actionValue] !== undefined) { const fieldValue = values[actionValue]; - if (fieldValue && typeof fieldValue === 'object' && fieldValue.value !== undefined) { + + // Handle single object with value property + if (fieldValue && typeof fieldValue === 'object' && !Array.isArray(fieldValue) && fieldValue.value !== undefined) { apiData[actionValue] = fieldValue.value; - } else { + } + // Handle array of objects with value property (for creatable autocomplete fields) + else if (Array.isArray(fieldValue)) { + apiData[actionValue] = fieldValue.map(item => { + if (item && typeof item === 'object' && item.value !== undefined) { + return item.value; + } + return item; + }); + } + // Handle plain values + else { apiData[actionValue] = fieldValue; } } @@ -402,9 +481,22 @@ export const CippTransportRuleDrawer = ({ const exceptionValue = exception.value || exception; if (values[exceptionValue] !== undefined) { const fieldValue = values[exceptionValue]; - if (fieldValue && typeof fieldValue === 'object' && fieldValue.value !== undefined) { + + // Handle single object with value property + if (fieldValue && typeof fieldValue === 'object' && !Array.isArray(fieldValue) && fieldValue.value !== undefined) { apiData[exceptionValue] = fieldValue.value; - } else { + } + // Handle array of objects with value property (for creatable autocomplete fields) + else if (Array.isArray(fieldValue)) { + apiData[exceptionValue] = fieldValue.map(item => { + if (item && typeof item === 'object' && item.value !== undefined) { + return item.value; + } + return item; + }); + } + // Handle plain values + else { apiData[exceptionValue] = fieldValue; } } @@ -535,8 +627,10 @@ export const CippTransportRuleDrawer = ({ const conditionOptions = [ { value: "From", label: "The sender is..." }, { value: "FromScope", label: "The sender is located..." }, + { value: "FromMemberOf", label: "The sender is a member of..." }, { value: "SentTo", label: "The recipient is..." }, { value: "SentToScope", label: "The recipient is located..." }, + { value: "SentToMemberOf", label: "The recipient is a member of..." }, { value: "SubjectContainsWords", label: "Subject contains words..." }, { value: "SubjectMatchesPatterns", label: "Subject matches patterns..." }, { value: "SubjectOrBodyContainsWords", label: "Subject or body contains words..." }, @@ -553,8 +647,19 @@ export const CippTransportRuleDrawer = ({ { value: "MessageTypeMatches", label: "Message type is..." }, { value: "SenderDomainIs", label: "Sender domain is..." }, { value: "RecipientDomainIs", label: "Recipient domain is..." }, + { value: "SenderIpRanges", label: "Sender IP address belongs to any of these ranges..." }, { value: "HeaderContainsWords", label: "Message header contains words..." }, { value: "HeaderMatchesPatterns", label: "Message header matches patterns..." }, + { value: "AnyOfToHeader", label: "Any To header contains..." }, + { value: "AnyOfToHeaderMemberOf", label: "Any To header is a member of..." }, + { value: "AnyOfCcHeader", label: "Any Cc header contains..." }, + { value: "AnyOfCcHeaderMemberOf", label: "Any Cc header is a member of..." }, + { value: "AnyOfToCcHeader", label: "Any To or Cc header contains..." }, + { value: "AnyOfToCcHeaderMemberOf", label: "Any To or Cc header is a member of..." }, + { value: "RecipientAddressContainsWords", label: "Recipient address contains words..." }, + { value: "RecipientAddressMatchesPatterns", label: "Recipient address matches patterns..." }, + { value: "AnyOfRecipientAddressContainsWords", label: "Any recipient address contains words..." }, + { value: "AnyOfRecipientAddressMatchesPatterns", label: "Any recipient address matches patterns..." }, ]; // Action options @@ -585,6 +690,9 @@ export const CippTransportRuleDrawer = ({ switch (conditionValue) { case "From": case "SentTo": + case "AnyOfToHeader": + case "AnyOfCcHeader": + case "AnyOfToCcHeader": return ( ); + case "FromMemberOf": + case "SentToMemberOf": + case "AnyOfToHeaderMemberOf": + case "AnyOfCcHeaderMemberOf": + case "AnyOfToCcHeaderMemberOf": + return ( + + `${option.displayName}${option.mail ? ` (${option.mail})` : ''}`, + valueField: "mail", + dataKey: "Results", + }} + /> + + ); + case "FromScope": case "SentToScope": return ( @@ -718,6 +857,22 @@ export const CippTransportRuleDrawer = ({ ); + case "SenderIpRanges": + return ( + + + + ); + case "HeaderContainsWords": case "HeaderMatchesPatterns": return ( diff --git a/src/components/CippComponents/CippUserActions.jsx b/src/components/CippComponents/CippUserActions.jsx index fb2473ec767f..0b365b506c77 100644 --- a/src/components/CippComponents/CippUserActions.jsx +++ b/src/components/CippComponents/CippUserActions.jsx @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import { EyeIcon, MagnifyingGlassIcon, TrashIcon } from "@heroicons/react/24/outline"; import { Archive, @@ -20,12 +21,160 @@ import { CloudSync, } from "@mui/icons-material"; import { getCippLicenseTranslation } from "../../utils/get-cipp-license-translation"; -import { useSettings } from "/src/hooks/use-settings.js"; +import { useSettings } from "../../hooks/use-settings.js"; import { usePermissions } from "../../hooks/use-permissions"; import { Tooltip, Box } from "@mui/material"; import CippFormComponent from "./CippFormComponent"; import { useWatch } from "react-hook-form"; +// Separate component for Manage Licenses form to avoid hook issues +const ManageLicensesForm = ({ formControl, tenant }) => { + const licenseOperation = useWatch({ + control: formControl.control, + name: "LicenseOperation", + }); + + const removeAllLicenses = useWatch({ + control: formControl.control, + name: "RemoveAllLicenses", + }); + + const replaceAllLicenses = useWatch({ + control: formControl.control, + name: "ReplaceAllLicenses", + }); + + // Handle both string values and object values with .value property + const licenseOpValue = licenseOperation?.value || licenseOperation; + + const isRemoveOperation = licenseOpValue === "Remove"; + const isReplaceOperation = licenseOpValue === "Replace"; + const showLicensesToRemove = isRemoveOperation && !removeAllLicenses; + const showLicensesToReplace = isReplaceOperation && !replaceAllLicenses; + + // Clear fields when operation changes to prevent stale data submission + useEffect(() => { + if (licenseOpValue) { + // Clear all license-related fields when switching operations + if (licenseOpValue === "Add") { + // Clear Remove/Replace specific fields + formControl.setValue("RemoveAllLicenses", false); + formControl.setValue("ReplaceAllLicenses", false); + formControl.setValue("LicensesToRemove", []); + formControl.setValue("LicensesToReplace", []); + } else if (licenseOpValue === "Remove") { + // Clear Add/Replace specific fields + formControl.setValue("ReplaceAllLicenses", false); + formControl.setValue("LicensesToReplace", []); + formControl.setValue("Licenses", []); + } else if (licenseOpValue === "Replace") { + // Clear Remove specific fields + formControl.setValue("RemoveAllLicenses", false); + formControl.setValue("LicensesToRemove", []); + } + } + }, [licenseOpValue, formControl]); + + // Clear LicensesToReplace when ReplaceAllLicenses is toggled + useEffect(() => { + if (isReplaceOperation && replaceAllLicenses) { + formControl.setValue("LicensesToReplace", []); + } + }, [replaceAllLicenses, isReplaceOperation, formControl]); + + return ( + <> + + + {isRemoveOperation && ( + + )} + + {isReplaceOperation && ( + + )} + + {showLicensesToRemove && ( + option.displayName || option.skuPartNumber, + valueField: "skuId", + queryKey: `ListLicenses-${tenant}`, + }} + /> + )} + + {showLicensesToReplace && ( + option.displayName || option.skuPartNumber, + valueField: "skuId", + queryKey: `ListLicenses-${tenant}`, + }} + /> + )} + + {(licenseOpValue === "Add" || isReplaceOperation) && ( + + `${option.displayName || option.skuPartNumber} (${ + option.availableUnits || 0 + } available)`, + valueField: "skuId", + queryKey: `ListLicenses-Available-${tenant}`, + }} + /> + )} + + ); +}; + // Separate component for Out of Office form to avoid hook issues const OutOfOfficeForm = ({ formControl }) => { // Watch the Auto Reply State value @@ -144,7 +293,8 @@ export const useCippUserActions = () => { type: "GET", icon: , link: "/identity/administration/users/user/bec?userId=[id]", - confirmText: "Are you sure you want to research this compromised account?", + confirmText: + "Are you sure you want to research if [userPrincipalName] is a compromised account?", multiPost: false, }, { @@ -173,7 +323,8 @@ export const useCippUserActions = () => { dateTimeType: "datetime", }, ], - confirmText: "Are you sure you want to create a Temporary Access Password?", + confirmText: + "Are you sure you want to create a Temporary Access Password for [userPrincipalName]?", multiPost: false, condition: () => canWriteUser, }, @@ -184,7 +335,7 @@ export const useCippUserActions = () => { icon: , url: "/api/ExecResetMFA", data: { ID: "userPrincipalName" }, - confirmText: "Are you sure you want to reset MFA for this user?", + confirmText: "Are you sure you want to reset MFA for [userPrincipalName]?", multiPost: false, condition: () => canWriteUser, }, @@ -195,7 +346,7 @@ export const useCippUserActions = () => { icon: , url: "/api/ExecSendPush", data: { UserEmail: "userPrincipalName" }, - confirmText: "Are you sure you want to send an MFA request?", + confirmText: "Are you sure you want to send an MFA request to [userPrincipalName]?", multiPost: false, }, { @@ -281,6 +432,7 @@ export const useCippUserActions = () => { icon: , url: "/api/EditGroup", customDataformatter: (row, action, formData) => { + // Build the member list from selected users let addMember = []; if (Array.isArray(row)) { row @@ -305,26 +457,32 @@ export const useCippUserActions = () => { }, }); } - return { + + // Handle multiple groups - return an array of requests (one per group) + const selectedGroups = Array.isArray(formData.groupId) + ? formData.groupId + : [formData.groupId]; + + return selectedGroups.map((group) => ({ addMember: addMember, tenantFilter: tenant, - groupId: formData.groupId, - }; + groupId: group, + })); }, fields: [ { type: "autoComplete", name: "groupId", - label: "Select a group to add the user to", - multiple: false, + label: "Select groups to add the user to", + multiple: true, creatable: false, - validators: { required: "Please select a group" }, + validators: { required: "Please select at least one group" }, api: { url: "/api/ListGroups", labelField: (option) => option?.calculatedGroupType ? `${option.displayName} (${option.calculatedGroupType})` - : option?.displayName ?? "", + : (option?.displayName ?? ""), valueField: "id", addedField: { groupType: "groupType", @@ -335,8 +493,8 @@ export const useCippUserActions = () => { }, }, ], - confirmText: "Are you sure you want to add [userPrincipalName] to this group?", - multiPost: true, + confirmText: "Are you sure you want to add [userPrincipalName] to the selected groups?", + multiPost: false, allowResubmit: true, condition: () => canWriteGroup, }, @@ -347,40 +505,11 @@ export const useCippUserActions = () => { icon: , data: { userIds: "id" }, multiPost: true, - fields: [ - { - type: "radio", - name: "LicenseOperation", - label: "License Operation", - options: [ - { label: "Add Licenses", value: "Add" }, - { label: "Remove Licenses", value: "Remove" }, - { label: "Replace Licenses", value: "Replace" }, - ], - validators: { required: "Please select a license operation" }, - }, - { - type: "switch", - name: "RemoveAllLicenses", - label: "Remove All Existing Licenses", - }, - { - type: "autoComplete", - name: "Licenses", - label: "Select Licenses", - multiple: true, - creatable: false, - api: { - url: "/api/ListLicenses", - labelField: (option) => - `${getCippLicenseTranslation([option])} (${option?.availableUnits} available)`, - valueField: "skuId", - queryKey: `licenses-${tenant}`, - }, - }, - ], + allowResubmit: true, + children: ({ formHook: formControl }) => ( + + ), confirmText: "Are you sure you want to manage licenses for the selected users?", - multiPost: true, condition: () => canWriteUser, }, { @@ -403,7 +532,7 @@ export const useCippUserActions = () => { icon: , url: "/api/ExecOneDriveProvision", data: { UserPrincipalName: "userPrincipalName" }, - confirmText: "Are you sure you want to pre-provision OneDrive for this user?", + confirmText: "Are you sure you want to pre-provision OneDrive for [userPrincipalName]?", multiPost: false, condition: () => canWriteUser, }, @@ -540,6 +669,17 @@ export const useCippUserActions = () => { "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, }, + { + label: "Reprocess License Assignments", + type: "POST", + icon: , + url: "/api/ExecReprocessUserLicenses", + data: { ID: "id", userPrincipalName: "userPrincipalName" }, + confirmText: + "Are you sure you want to reprocess license assignments for [userPrincipalName]?", + multiPost: false, + condition: (row) => canWriteUser, + }, { label: "Revoke all user sessions", type: "POST", diff --git a/src/components/CippComponents/CippVariableAutocomplete.jsx b/src/components/CippComponents/CippVariableAutocomplete.jsx index 9910e9771afd..49e89fda27ee 100644 --- a/src/components/CippComponents/CippVariableAutocomplete.jsx +++ b/src/components/CippComponents/CippVariableAutocomplete.jsx @@ -9,9 +9,9 @@ import { useTheme, CircularProgress, } from "@mui/material"; -import { ApiGetCall } from "/src/api/ApiCall"; -import { useSettings } from "/src/hooks/use-settings.js"; -import { getCippError } from "/src/utils/get-cipp-error"; +import { ApiGetCall } from "../../api/ApiCall"; +import { useSettings } from "../../hooks/use-settings.js"; +import { getCippError } from "../../utils/get-cipp-error"; /** * Autocomplete component specifically for custom variables diff --git a/src/components/CippComponents/DesktopDevicesSankey.jsx b/src/components/CippComponents/DesktopDevicesSankey.jsx new file mode 100644 index 000000000000..4ffd02318ab8 --- /dev/null +++ b/src/components/CippComponents/DesktopDevicesSankey.jsx @@ -0,0 +1,50 @@ +import { CippSankey } from "./CippSankey"; + +export const DesktopDevicesSankey = ({ data }) => { + //temporary mock sankey for dash - dont delete until replaced. + return ( + + ); +}; diff --git a/src/components/CippComponents/DomainAnalyserDialog.jsx b/src/components/CippComponents/DomainAnalyserDialog.jsx index 803baf43f154..ed5a6a948119 100644 --- a/src/components/CippComponents/DomainAnalyserDialog.jsx +++ b/src/components/CippComponents/DomainAnalyserDialog.jsx @@ -3,7 +3,7 @@ import { Dialog, DialogContent, DialogTitle, Button, DialogActions } from "@mui/ import { Refresh } from "@mui/icons-material"; import { useForm, FormProvider } from "react-hook-form"; import { CippFormTenantSelector } from "./CippFormTenantSelector"; -import { ApiPostCall } from "/src/api/ApiCall"; +import { ApiPostCall } from "../../api/ApiCall"; import { CippApiResults } from "./CippApiResults"; export const DomainAnalyserDialog = ({ createDialog }) => { diff --git a/src/components/CippComponents/LicenseCard.jsx b/src/components/CippComponents/LicenseCard.jsx new file mode 100644 index 000000000000..dce02b1e12f6 --- /dev/null +++ b/src/components/CippComponents/LicenseCard.jsx @@ -0,0 +1,190 @@ +import { Box, Card, CardHeader, CardContent, Typography, Divider, Skeleton } from "@mui/material"; +import { CardMembership as CardMembershipIcon } from "@mui/icons-material"; +import { CippSankey } from "./CippSankey"; + +export const LicenseCard = ({ data, isLoading }) => { + const processData = () => { + if (!data || !Array.isArray(data) || data.length === 0) { + return null; + } + + const topLicenses = data + .filter((license) => license && parseInt(license?.TotalLicenses || 0) > 0) + .sort((a, b) => parseInt(b?.TotalLicenses || 0) - parseInt(a?.TotalLicenses || 0)) + .slice(0, 5); + + if (topLicenses.length === 0) { + return null; + } + + const nodes = []; + const links = []; + + topLicenses.forEach((license, index) => { + if (license) { + const licenseName = + license.License || license.skuPartNumber || license.SkuPartNumber || "Unknown License"; + const shortName = + licenseName.length > 30 ? licenseName.substring(0, 27) + "..." : licenseName; + + const assigned = parseInt(license?.CountUsed || 0) || 0; + const available = parseInt(license?.CountAvailable || 0) || 0; + + nodes.push({ + id: shortName, + nodeColor: `hsl(${210 + index * 30}, 70%, 50%)`, + }); + + const assignedId = `${shortName} - Assigned`; + const availableId = `${shortName} - Available`; + + if (assigned > 0) { + nodes.push({ + id: assignedId, + nodeColor: "hsl(99, 70%, 50%)", + }); + + links.push({ + source: shortName, + target: assignedId, + value: assigned, + }); + } + + if (available > 0) { + nodes.push({ + id: availableId, + nodeColor: "hsl(28, 100%, 53%)", + }); + + links.push({ + source: shortName, + target: availableId, + value: available, + }); + } + } + }); + + if (nodes.length === 0 || links.length === 0) { + return null; + } + + return { nodes, links }; + }; + + const processedData = processData(); + + const calculateStats = () => { + if (!data || !Array.isArray(data)) { + return { total: 0, assigned: 0, available: 0 }; + } + + return { + total: data.reduce((sum, lic) => sum + (parseInt(lic?.TotalLicenses || 0) || 0), 0), + assigned: data.reduce((sum, lic) => sum + (parseInt(lic?.CountUsed || 0) || 0), 0), + available: data.reduce((sum, lic) => sum + (parseInt(lic?.CountAvailable || 0) || 0), 0), + }; + }; + + const stats = calculateStats(); + + return ( + + + + License Overview + + } + sx={{ pb: 1 }} + /> + + + {isLoading ? ( + + ) : processedData ? ( + + ) : ( + + + No license data available + + + )} + + + + + {isLoading ? ( + + + + + + + + + + + + + + + + + ) : data && Array.isArray(data) && data.length > 0 ? ( + + + + Total Licenses + + + {stats.total.toLocaleString()} + + + + + + Assigned + + + {stats.assigned.toLocaleString()} + + + + + + Available + + + {stats.available.toLocaleString()} + + + + ) : ( + + + No license statistics available + + + )} + + + ); +}; diff --git a/src/components/CippComponents/LicenseSankey.jsx b/src/components/CippComponents/LicenseSankey.jsx new file mode 100644 index 000000000000..fd4e1763f260 --- /dev/null +++ b/src/components/CippComponents/LicenseSankey.jsx @@ -0,0 +1,77 @@ +import { CippSankey } from "./CippSankey"; + +export const LicenseSankey = ({ data }) => { + // Null safety checks + if (!data || !Array.isArray(data) || data.length === 0) { + return null; + } + + // Get top 5 licenses by total count with null safety + const topLicenses = data + .filter((license) => license && parseInt(license?.TotalLicenses || 0) > 0) + .sort((a, b) => parseInt(b?.TotalLicenses || 0) - parseInt(a?.TotalLicenses || 0)) + .slice(0, 5); + + if (topLicenses.length === 0) { + return null; + } + + // Create Sankey flow: Top 5 Licenses -> Assigned/Available for each + const nodes = []; + const links = []; + + topLicenses.forEach((license, index) => { + if (license) { + const licenseName = + license.License || license.skuPartNumber || license.SkuPartNumber || "Unknown License"; + const shortName = + licenseName.length > 30 ? licenseName.substring(0, 27) + "..." : licenseName; + + const assigned = parseInt(license?.CountUsed || 0) || 0; + const available = parseInt(license?.CountAvailable || 0) || 0; + + // Add license node + nodes.push({ + id: shortName, + nodeColor: `hsl(${210 + index * 30}, 70%, 50%)`, + }); + + // Add Assigned and Available nodes for this license + const assignedId = `${shortName} - Assigned`; + const availableId = `${shortName} - Available`; + + if (assigned > 0) { + nodes.push({ + id: assignedId, + nodeColor: "hsl(99, 70%, 50%)", + }); + + links.push({ + source: shortName, + target: assignedId, + value: assigned, + }); + } + + if (available > 0) { + nodes.push({ + id: availableId, + nodeColor: "hsl(28, 100%, 53%)", + }); + + links.push({ + source: shortName, + target: availableId, + value: available, + }); + } + } + }); + + // Only render if we have valid data + if (nodes.length === 0 || links.length === 0) { + return null; + } + + return ; +}; diff --git a/src/components/CippComponents/MFACard.jsx b/src/components/CippComponents/MFACard.jsx new file mode 100644 index 000000000000..e166c3b496a0 --- /dev/null +++ b/src/components/CippComponents/MFACard.jsx @@ -0,0 +1,256 @@ +import { Box, Card, CardHeader, CardContent, Typography, Skeleton } from "@mui/material"; +import { Person as UserIcon } from "@mui/icons-material"; +import { CippSankey } from "./CippSankey"; +import { useRouter } from "next/router"; + +export const MFACard = ({ data, isLoading }) => { + const router = useRouter(); + // Process data inside component + const processData = () => { + if (!data || !Array.isArray(data) || data.length === 0) { + return null; + } + + const enabledUsers = data.filter((user) => user.AccountEnabled === true); + if (enabledUsers.length === 0) { + return null; + } + + let registeredUsers = 0; + let notRegisteredUsers = 0; + let registeredCA = 0; + let registeredSD = 0; + let registeredPerUser = 0; + let registeredNone = 0; + let notRegisteredCA = 0; + let notRegisteredSD = 0; + let notRegisteredPerUser = 0; + let notRegisteredNone = 0; + + enabledUsers.forEach((user) => { + const hasRegistered = user.MFARegistration === true; + const coveredByCA = user.CoveredByCA?.startsWith("Enforced") || false; + const coveredBySD = user.CoveredBySD === true; + const perUserEnabled = user.PerUser === "enforced" || user.PerUser === "enabled"; + + if (hasRegistered || perUserEnabled) { + registeredUsers++; + if (perUserEnabled) { + registeredPerUser++; + } else if (coveredByCA) { + registeredCA++; + } else if (coveredBySD) { + registeredSD++; + } else { + registeredNone++; + } + } else { + notRegisteredUsers++; + if (coveredByCA) { + notRegisteredCA++; + } else if (coveredBySD) { + notRegisteredSD++; + } else { + notRegisteredNone++; + } + } + }); + + const registeredPercentage = ((registeredUsers / enabledUsers.length) * 100).toFixed(1); + const protectedPercentage = ( + ((registeredCA + registeredSD + registeredPerUser) / enabledUsers.length) * + 100 + ).toFixed(1); + + const links = [ + { source: "Enabled users", target: "MFA registered", value: registeredUsers }, + { source: "Enabled users", target: "Not registered", value: notRegisteredUsers }, + ]; + + if (registeredCA > 0) + links.push({ source: "MFA registered", target: "CA policy", value: registeredCA }); + if (registeredSD > 0) + links.push({ source: "MFA registered", target: "Security defaults", value: registeredSD }); + if (registeredPerUser > 0) + links.push({ source: "MFA registered", target: "Per-user MFA", value: registeredPerUser }); + if (registeredNone > 0) + links.push({ source: "MFA registered", target: "No enforcement", value: registeredNone }); + + if (notRegisteredCA > 0) + links.push({ source: "Not registered", target: "CA policy", value: notRegisteredCA }); + if (notRegisteredSD > 0) + links.push({ + source: "Not registered", + target: "Security defaults", + value: notRegisteredSD, + }); + if (notRegisteredPerUser > 0) + links.push({ source: "Not registered", target: "Per-user MFA", value: notRegisteredPerUser }); + if (notRegisteredNone > 0) + links.push({ source: "Not registered", target: "No enforcement", value: notRegisteredNone }); + + const description = `${registeredPercentage}% of enabled users have registered MFA methods. ${protectedPercentage}% are protected by policies requiring MFA.`; + + return { + nodes: [ + { id: "Enabled users", nodeColor: "hsl(28, 100%, 53%)" }, + { id: "MFA registered", nodeColor: "hsl(99, 70%, 50%)" }, + { id: "Not registered", nodeColor: "hsl(39, 100%, 50%)" }, + { id: "CA policy", nodeColor: "hsl(99, 70%, 50%)" }, + { id: "Security defaults", nodeColor: "hsl(140, 70%, 50%)" }, + { id: "Per-user MFA", nodeColor: "hsl(200, 70%, 50%)" }, + { id: "No enforcement", nodeColor: "hsl(0, 100%, 50%)" }, + ], + links, + description, + }; + }; + + const processedData = processData(); + + const handleNodeClick = (node) => { + // Build filter based on clicked node + let filters = []; + + switch (node.id) { + case "Enabled users": + filters = [{ id: "AccountEnabled", value: "Yes" }]; + break; + case "MFA registered": + filters = [ + { id: "AccountEnabled", value: "Yes" }, + { id: "MFARegistration", value: "Yes" }, + ]; + break; + case "Not registered": + filters = [ + { id: "AccountEnabled", value: "Yes" }, + { id: "MFARegistration", value: "No" }, + ]; + break; + default: + // For other nodes, don't navigate + return; + } + + // Navigate to MFA report with filters + router.push({ + pathname: "/identity/reports/mfa-report", + query: { filters: JSON.stringify(filters) }, + }); + }; + + const handleLinkClick = (link) => { + // Build filters based on the link's source and target + let filters = []; + + if (link.source.id === "Enabled users" && link.target.id === "MFA registered") { + filters = [ + { id: "AccountEnabled", value: "Yes" }, + { id: "MFARegistration", value: "Yes" }, + ]; + } else if (link.source.id === "Enabled users" && link.target.id === "Not registered") { + filters = [ + { id: "AccountEnabled", value: "Yes" }, + { id: "MFARegistration", value: "No" }, + ]; + } else if (link.source.id === "MFA registered" && link.target.id === "CA policy") { + filters = [ + { id: "AccountEnabled", value: "Yes" }, + { id: "MFARegistration", value: "Yes" }, + ]; + // Note: We can't easily filter by CoveredByCA in the table since it needs complex logic + } else if (link.source.id === "MFA registered" && link.target.id === "Security defaults") { + filters = [ + { id: "AccountEnabled", value: "Yes" }, + { id: "MFARegistration", value: "Yes" }, + { id: "CoveredBySD", value: "Yes" }, + ]; + } else if (link.source.id === "MFA registered" && link.target.id === "Per-user MFA") { + filters = [{ id: "AccountEnabled", value: "Yes" }]; + // Note: Per-user MFA can be "enabled" or "enforced" + } else if (link.source.id === "MFA registered" && link.target.id === "No enforcement") { + filters = [ + { id: "AccountEnabled", value: "Yes" }, + { id: "MFARegistration", value: "Yes" }, + ]; + } else if (link.source.id === "Not registered" && link.target.id === "CA policy") { + filters = [ + { id: "AccountEnabled", value: "Yes" }, + { id: "MFARegistration", value: "No" }, + ]; + } else if (link.source.id === "Not registered" && link.target.id === "Security defaults") { + filters = [ + { id: "AccountEnabled", value: "Yes" }, + { id: "MFARegistration", value: "No" }, + { id: "CoveredBySD", value: "Yes" }, + ]; + } else if (link.source.id === "Not registered" && link.target.id === "Per-user MFA") { + filters = [ + { id: "AccountEnabled", value: "Yes" }, + { id: "MFARegistration", value: "No" }, + ]; + } else if (link.source.id === "Not registered" && link.target.id === "No enforcement") { + filters = [ + { id: "AccountEnabled", value: "Yes" }, + { id: "MFARegistration", value: "No" }, + ]; + } + + // Navigate to MFA report with filters + if (filters.length > 0) { + router.push({ + pathname: "/identity/reports/mfa-report", + query: { filters: JSON.stringify(filters) }, + }); + } + }; + + return ( + + + + User authentication +
+ } + sx={{ pb: 1 }} + /> + + + {isLoading ? ( + + ) : processedData ? ( + + ) : ( + + + No MFA data available + + + )} + + + {!isLoading && processedData?.description && ( + + + {processedData.description} + + + )} + + ); +}; diff --git a/src/components/CippComponents/MFASankey.jsx b/src/components/CippComponents/MFASankey.jsx new file mode 100644 index 000000000000..9fb387cefa9d --- /dev/null +++ b/src/components/CippComponents/MFASankey.jsx @@ -0,0 +1,140 @@ +import { CippSankey } from "./CippSankey"; + +export const MFASankey = ({ data }) => { + // Null safety checks + if (!data || !Array.isArray(data) || data.length === 0) { + return null; + } + + // Count enabled users only + const enabledUsers = data.filter((user) => user.AccountEnabled === true); + + if (enabledUsers.length === 0) { + return null; + } + + // Split by MFA registration status + let registeredUsers = 0; + let notRegisteredUsers = 0; + + // For registered users, split by protection method + let registeredCA = 0; + let registeredSD = 0; + let registeredPerUser = 0; + let registeredNone = 0; + + // For not registered users, split by protection method + let notRegisteredCA = 0; + let notRegisteredSD = 0; + let notRegisteredPerUser = 0; + let notRegisteredNone = 0; + + enabledUsers.forEach((user) => { + const hasRegistered = user.MFARegistration === true; + const coveredByCA = user.CoveredByCA?.startsWith("Enforced") || false; + const coveredBySD = user.CoveredBySD === true; + const perUserEnabled = user.PerUser === "enforced" || user.PerUser === "enabled"; + + // Consider PerUser as MFA enabled/registered + if (hasRegistered || perUserEnabled) { + registeredUsers++; + // Per-User gets its own separate terminal path + if (perUserEnabled) { + registeredPerUser++; + } else if (coveredByCA) { + registeredCA++; + } else if (coveredBySD) { + registeredSD++; + } else { + registeredNone++; + } + } else { + notRegisteredUsers++; + if (coveredByCA) { + notRegisteredCA++; + } else if (coveredBySD) { + notRegisteredSD++; + } else { + notRegisteredNone++; + } + } + }); + + const registeredPercentage = ((registeredUsers / enabledUsers.length) * 100).toFixed(1); + const protectedPercentage = ( + ((registeredCA + registeredSD + registeredPerUser) / enabledUsers.length) * + 100 + ).toFixed(1); + + const links = [ + { source: "Enabled users", target: "MFA registered", value: registeredUsers }, + { source: "Enabled users", target: "Not registered", value: notRegisteredUsers }, + ]; + + // Add protection methods for registered users + if (registeredCA > 0) + links.push({ source: "MFA registered", target: "CA policy", value: registeredCA }); + if (registeredSD > 0) + links.push({ source: "MFA registered", target: "Security defaults", value: registeredSD }); + if (registeredPerUser > 0) + links.push({ source: "MFA registered", target: "Per-user MFA", value: registeredPerUser }); + if (registeredNone > 0) + links.push({ source: "MFA registered", target: "No enforcement", value: registeredNone }); + + // Add protection methods for not registered users + if (notRegisteredCA > 0) + links.push({ source: "Not registered", target: "CA policy", value: notRegisteredCA }); + if (notRegisteredSD > 0) + links.push({ source: "Not registered", target: "Security defaults", value: notRegisteredSD }); + if (notRegisteredPerUser > 0) + links.push({ source: "Not registered", target: "Per-user MFA", value: notRegisteredPerUser }); + if (notRegisteredNone > 0) + links.push({ source: "Not registered", target: "No enforcement", value: notRegisteredNone }); + + const description = `${registeredPercentage}% of enabled users have registered MFA methods. ${protectedPercentage}% are protected by policies requiring MFA.`; + + return ( + <> + + {description && ( +
+ {description} +
+ )} + + ); +}; diff --git a/src/components/CippComponents/MobileSankey.jsx b/src/components/CippComponents/MobileSankey.jsx new file mode 100644 index 000000000000..56da01903dc9 --- /dev/null +++ b/src/components/CippComponents/MobileSankey.jsx @@ -0,0 +1,49 @@ +import { CippSankey } from "./CippSankey"; + +export const MobileSankey = ({ data }) => { + return ( + + ); +}; diff --git a/src/components/CippComponents/ScheduledTaskDetails.jsx b/src/components/CippComponents/ScheduledTaskDetails.jsx index 554c72261c4b..51991b1b88a7 100644 --- a/src/components/CippComponents/ScheduledTaskDetails.jsx +++ b/src/components/CippComponents/ScheduledTaskDetails.jsx @@ -19,11 +19,12 @@ import { CippPropertyListCard } from "../CippCards/CippPropertyListCard"; import { ExpandMore, Sync, Search, Close } from "@mui/icons-material"; import { getCippFormatting } from "../../utils/get-cipp-formatting"; import { CippDataTable } from "../CippTable/CippDataTable"; -import { CippTimeAgo } from "/src/components/CippComponents/CippTimeAgo"; -import { ActionsMenu } from "/src/components/actions-menu"; +import { CippTimeAgo } from "./CippTimeAgo"; +import { ActionsMenu } from "../actions-menu"; import { CippScheduledTaskActions } from "./CippScheduledTaskActions"; +import { CippApiLogsDrawer } from "./CippApiLogsDrawer"; -const ScheduledTaskDetails = ({ data, showActions = true }) => { +const ScheduledTaskDetails = ({ data, showActions = true, showTitle = true }) => { const [taskDetails, setTaskDetails] = useState(null); const [expanded, setExpanded] = useState(false); const [searchQuery, setSearchQuery] = useState(""); @@ -81,12 +82,18 @@ const ScheduledTaskDetails = ({ data, showActions = true }) => { return ( <> - + - {taskDetailResults.isLoading ? : taskDetails?.Task?.Name} + {showTitle && (taskDetailResults.isLoading ? : taskDetails?.Task?.Name)} {showActions && ( - + + { noCard data={result.Results} disablePagination={result.Results.length <= 10} + refreshFunction={() => taskDetailResults.refetch()} /> ) : typeof result.Results === "object" ? ( { + return ( + + + + Secure Score + + } + sx={{ pb: 1 }} + /> + + {isLoading ? ( + <> + + + + + + + The Secure Score measures your security posture across your tenant. + + + ) : !data || !Array.isArray(data) || data.length === 0 ? ( + <> + + + + No secure score data available + + + + + The Secure Score measures your security posture across your tenant. + + + ) : ( + <> + + + {(() => { + const sortedData = [...data].sort((a, b) => new Date(a.createdDateTime) - new Date(b.createdDateTime)); + const chartData = sortedData.map((score) => ({ + date: new Date(score.createdDateTime).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }), + score: score.currentScore, + percentage: Math.round((score.currentScore / score.maxScore) * 100), + })); + const ticks = chartData.map((d) => d.date); + return ( + + + + Math.round(value)} + /> + { + if (name === "score") return [value.toFixed(2), "Score"]; + if (name === "percentage") return [value + "%", "Percentage"]; + return value; + }} + /> + + + ); + })()} + + + + The Secure Score measures your security posture across your tenant. + + + )} + + + + {isLoading ? ( + + + + + + + + + + + + + + ) : !data || !Array.isArray(data) || data.length === 0 ? ( + + Enable secure score monitoring in your tenant + + ) : ( + + + + Latest % + + + {Math.round( + (data[data.length - 1].currentScore / data[data.length - 1].maxScore) * 100 + )} + % + + + + + + Current Score + + + {data[data.length - 1].currentScore.toFixed(2)} + + + + + + Max Score + + + {data[data.length - 1].maxScore.toFixed(2)} + + + + )} + + + ); +}; diff --git a/src/components/CippComponents/SecureScoreChart.jsx b/src/components/CippComponents/SecureScoreChart.jsx new file mode 100644 index 000000000000..f9830d128481 --- /dev/null +++ b/src/components/CippComponents/SecureScoreChart.jsx @@ -0,0 +1,153 @@ +import { Box, Typography, Divider, Skeleton } from "@mui/material"; +import { + LineChart, + Line, + CartesianGrid, + XAxis, + YAxis, + ResponsiveContainer, + Tooltip as RechartsTooltip, +} from "recharts"; + +export const SecureScoreChart = ({ data, isLoading }) => { + if (isLoading) { + return ( + <> + + + + + + + The Secure Score measures your security posture across your tenant. + + + + + + + + + + + + + + + + + ); + } + + if (!data || !Array.isArray(data) || data.length === 0) { + return ( + <> + + + + No secure score data available + + + + + The Secure Score measures your security posture across your tenant. + + + + + Enable secure score monitoring in your tenant + + + + ); + } + + const sortedData = [...data].sort( + (a, b) => new Date(a.createdDateTime) - new Date(b.createdDateTime) + ); + + const chartData = sortedData.map((score) => ({ + date: new Date(score.createdDateTime).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }), + score: score.currentScore, + percentage: Math.round((score.currentScore / score.maxScore) * 100), + })); + + const latestScore = sortedData[sortedData.length - 1]; + const latestPercentage = Math.round((latestScore.currentScore / latestScore.maxScore) * 100); + + return ( + <> + + + + + + + { + if (name === "score") return [value.toFixed(2), "Score"]; + if (name === "percentage") return [value + "%", "Percentage"]; + return value; + }} + /> + + + + + + The Secure Score measures your security posture across your tenant. + + + + + + Latest % + + + {latestPercentage}% + + + + + + Current Score + + + {latestScore.currentScore.toFixed(2)} + + + + + + Max Score + + + {latestScore.maxScore.toFixed(2)} + + + + + ); +}; diff --git a/src/components/CippComponents/TenantInfoCard.jsx b/src/components/CippComponents/TenantInfoCard.jsx new file mode 100644 index 000000000000..cd4b753e8f2c --- /dev/null +++ b/src/components/CippComponents/TenantInfoCard.jsx @@ -0,0 +1,70 @@ +import { Box, Card, CardHeader, CardContent, Typography, Skeleton } from "@mui/material"; +import { Business as BuildingIcon } from "@mui/icons-material"; +import { CippCopyToClipBoard } from "./CippCopyToClipboard"; + +export const TenantInfoCard = ({ data, isLoading }) => { + return ( + + + + Tenant + + } + sx={{ pb: 1.5 }} + /> + + + + + Name + + {isLoading ? ( + + ) : ( + + {data?.displayName || "Not Available"} + + )} + + + + Tenant ID + + + {isLoading ? ( + + ) : data?.id ? ( + + ) : ( + + Not Available + + )} + + + + + Primary Domain + + + {isLoading ? ( + + ) : data?.verifiedDomains?.find((d) => d.isDefault)?.name ? ( + d.isDefault).name} + type="chip" + /> + ) : ( + + Not Available + + )} + + + + + + ); +}; diff --git a/src/components/CippComponents/TenantMetricsGrid.jsx b/src/components/CippComponents/TenantMetricsGrid.jsx new file mode 100644 index 000000000000..b8b0cfacc272 --- /dev/null +++ b/src/components/CippComponents/TenantMetricsGrid.jsx @@ -0,0 +1,100 @@ +import { Box, Grid, Tooltip, Avatar, Typography, Skeleton } from "@mui/material"; +import { + Person as UserIcon, + PersonOutline as GuestIcon, + Group as GroupIcon, + Apps as AppsIcon, + Devices as DevicesIcon, + PhoneAndroid as ManagedIcon, +} from "@mui/icons-material"; + +const formatNumber = (num) => { + if (num >= 1000000) return (num / 1000000).toFixed(1) + "M"; + if (num >= 1000) return (num / 1000).toFixed(1) + "K"; + return num?.toString() || "0"; +}; + +export const TenantMetricsGrid = ({ data, isLoading }) => { + const metrics = [ + { + label: "Users", + value: data?.UserCount || 0, + icon: UserIcon, + color: "primary", + }, + { + label: "Guests", + value: data?.GuestCount || 0, + icon: GuestIcon, + color: "info", + }, + { + label: "Groups", + value: data?.GroupCount || 0, + icon: GroupIcon, + color: "secondary", + }, + { + label: "Service Principals", + value: data?.ApplicationCount || 0, + icon: AppsIcon, + color: "error", + }, + { + label: "Devices", + value: data?.DeviceCount || 0, + icon: DevicesIcon, + color: "warning", + }, + { + label: "Managed", + value: data?.ManagedDeviceCount || 0, + icon: ManagedIcon, + color: "success", + }, + ]; + + return ( + + {metrics.map((metric) => { + const IconComponent = metric.icon; + return ( + + + + + + + + + {metric.label} + + + {isLoading ? : formatNumber(metric.value)} + + + + + + ); + })} + + ); +}; diff --git a/src/components/CippFormPages/CippAddAssignmentFilterForm.jsx b/src/components/CippFormPages/CippAddAssignmentFilterForm.jsx index 447b6ae4012d..551ec914e35b 100644 --- a/src/components/CippFormPages/CippAddAssignmentFilterForm.jsx +++ b/src/components/CippFormPages/CippAddAssignmentFilterForm.jsx @@ -1,7 +1,7 @@ import "@mui/material"; import { Grid } from "@mui/system"; import { useWatch } from "react-hook-form"; -import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; +import CippFormComponent from "../CippComponents/CippFormComponent"; const DEVICE_PLATFORM_OPTIONS = [ { label: "Windows 10 and later", value: "windows10AndLater" }, diff --git a/src/components/CippFormPages/CippAddAssignmentFilterTemplateForm.jsx b/src/components/CippFormPages/CippAddAssignmentFilterTemplateForm.jsx index 2b43d8f4c6b6..626b15db6e7a 100644 --- a/src/components/CippFormPages/CippAddAssignmentFilterTemplateForm.jsx +++ b/src/components/CippFormPages/CippAddAssignmentFilterTemplateForm.jsx @@ -1,7 +1,7 @@ import "@mui/material"; import { Grid } from "@mui/system"; import { useWatch } from "react-hook-form"; -import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; +import CippFormComponent from "../CippComponents/CippFormComponent"; const DEVICE_PLATFORM_OPTIONS = [ { label: "Windows 10 and later", value: "windows10AndLater" }, diff --git a/src/components/CippFormPages/CippAddEditContact.jsx b/src/components/CippFormPages/CippAddEditContact.jsx index cbc96616d37c..1e038f8231a5 100644 --- a/src/components/CippFormPages/CippAddEditContact.jsx +++ b/src/components/CippFormPages/CippAddEditContact.jsx @@ -1,8 +1,8 @@ import { Divider } from "@mui/material"; import { Grid } from "@mui/system"; -import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; -import { getCippValidator } from "/src/utils/get-cipp-validator"; -import countryList from "/src/data/countryList.json"; +import CippFormComponent from "../CippComponents/CippFormComponent"; +import { getCippValidator } from "../../utils/get-cipp-validator"; +import countryList from "../../data/countryList.json"; const countryOptions = countryList.map(({ Code, Name }) => ({ label: Name, diff --git a/src/components/CippFormPages/CippAddEditGdapRoleTemplate.jsx b/src/components/CippFormPages/CippAddEditGdapRoleTemplate.jsx index 1ed0e4163f88..891935f0f99a 100644 --- a/src/components/CippFormPages/CippAddEditGdapRoleTemplate.jsx +++ b/src/components/CippFormPages/CippAddEditGdapRoleTemplate.jsx @@ -1,5 +1,5 @@ import { Alert, Box, Stack, Typography } from "@mui/material"; -import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; +import CippFormComponent from "../CippComponents/CippFormComponent"; export const CippAddEditGdapRoleTemplate = (props) => { const { formControl, availableRoles } = props; diff --git a/src/components/CippFormPages/CippAddEditUser.jsx b/src/components/CippFormPages/CippAddEditUser.jsx index 4e89a39eeedb..87e8088861a0 100644 --- a/src/components/CippFormPages/CippAddEditUser.jsx +++ b/src/components/CippFormPages/CippAddEditUser.jsx @@ -1,10 +1,10 @@ import { Alert, Divider, InputAdornment, Typography } from "@mui/material"; -import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; -import { CippFormCondition } from "/src/components/CippComponents/CippFormCondition"; -import { CippFormDomainSelector } from "/src/components/CippComponents/CippFormDomainSelector"; -import { CippFormUserSelector } from "/src/components/CippComponents/CippFormUserSelector"; -import countryList from "/src/data/countryList.json"; -import { CippFormLicenseSelector } from "/src/components/CippComponents/CippFormLicenseSelector"; +import CippFormComponent from "../CippComponents/CippFormComponent"; +import { CippFormCondition } from "../CippComponents/CippFormCondition"; +import { CippFormDomainSelector } from "../CippComponents/CippFormDomainSelector"; +import { CippFormUserSelector } from "../CippComponents/CippFormUserSelector"; +import countryList from "../../data/countryList.json"; +import { CippFormLicenseSelector } from "../CippComponents/CippFormLicenseSelector"; import { Grid } from "@mui/system"; import { ApiGetCall } from "../../api/ApiCall"; import { useSettings } from "../../hooks/use-settings"; @@ -606,9 +606,10 @@ const CippAddEditUser = (props) => { label: tenantGroup.displayName, value: tenantGroup.id, addedFields: { - calculatedGroupType: tenantGroup.calculatedGroupType, + groupType: tenantGroup.groupType, }, }))} + creatable={false} formControl={formControl} /> @@ -624,9 +625,10 @@ const CippAddEditUser = (props) => { label: userGroups.DisplayName, value: userGroups.id, addedFields: { - calculatedGroupType: userGroups.calculatedGroupType, + groupType: userGroups.groupType, }, }))} + creatable={false} formControl={formControl} /> @@ -717,6 +719,14 @@ const CippAddEditUser = (props) => { name="postExecution.psa" formControl={formControl} /> + diff --git a/src/components/CippFormPages/CippAddGroupForm.jsx b/src/components/CippFormPages/CippAddGroupForm.jsx index 9644b0426faf..713bb414c638 100644 --- a/src/components/CippFormPages/CippAddGroupForm.jsx +++ b/src/components/CippFormPages/CippAddGroupForm.jsx @@ -1,7 +1,7 @@ import { InputAdornment } from "@mui/material"; import { Grid } from "@mui/system"; -import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; -import { CippFormCondition } from "/src/components/CippComponents/CippFormCondition"; +import CippFormComponent from "../CippComponents/CippFormComponent"; +import { CippFormCondition } from "../CippComponents/CippFormCondition"; import { CippFormDomainSelector } from "../CippComponents/CippFormDomainSelector"; import { CippFormUserSelector } from "../CippComponents/CippFormUserSelector"; diff --git a/src/components/CippFormPages/CippAddGroupTemplateForm.jsx b/src/components/CippFormPages/CippAddGroupTemplateForm.jsx index 8db3b1ebea3f..36747ee70f32 100644 --- a/src/components/CippFormPages/CippAddGroupTemplateForm.jsx +++ b/src/components/CippFormPages/CippAddGroupTemplateForm.jsx @@ -1,8 +1,8 @@ import { useEffect } from "react"; import "@mui/material"; import { Grid } from "@mui/system"; -import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; -import { CippFormCondition } from "/src/components/CippComponents/CippFormCondition"; +import CippFormComponent from "../CippComponents/CippFormComponent"; +import { CippFormCondition } from "../CippComponents/CippFormCondition"; const CippAddGroupTemplateForm = (props) => { const { formControl } = props; diff --git a/src/components/CippFormPages/CippCustomDataMappingForm.jsx b/src/components/CippFormPages/CippCustomDataMappingForm.jsx index acb0c7134a56..8a5d30af5da7 100644 --- a/src/components/CippFormPages/CippCustomDataMappingForm.jsx +++ b/src/components/CippFormPages/CippCustomDataMappingForm.jsx @@ -1,13 +1,13 @@ import { useWatch } from "react-hook-form"; import { Box, Stack, Typography, Divider } from "@mui/material"; import { Grid } from "@mui/system"; -import { CippFormComponent } from "/src/components/CippComponents/CippFormComponent"; -import { CippFormTenantSelector } from "/src/components/CippComponents/CippFormTenantSelector"; -import { CippFormCondition } from "/src/components/CippComponents/CippFormCondition"; -import { CippPropertyListCard } from "/src/components/CippCards/CippPropertyListCard"; -import { CippCopyToClipBoard } from "/src/components/CippComponents/CippCopyToClipboard"; -import extensionDataMapping from "/src/data/extensionDataMapping"; -import { getCippTranslation } from "/src/utils/get-cipp-translation"; +import { CippFormComponent } from "../CippComponents/CippFormComponent"; +import { CippFormTenantSelector } from "../CippComponents/CippFormTenantSelector"; +import { CippFormCondition } from "../CippComponents/CippFormCondition"; +import { CippPropertyListCard } from "../CippCards/CippPropertyListCard"; +import { CippCopyToClipBoard } from "../CippComponents/CippCopyToClipboard"; +import extensionDataMapping from "../../data/extensionDataMapping"; +import { getCippTranslation } from "../../utils/get-cipp-translation"; const CippCustomDataMappingForm = ({ formControl }) => { const selectedAttribute = useWatch({ control: formControl.control, name: "customDataAttribute" }); @@ -42,7 +42,7 @@ const CippCustomDataMappingForm = ({ formControl }) => { multiple: false, placeholder: "Select a Source Type", options: [ - { value: "extensionSync", label: "Extension Sync" }, + { value: "reportingDb", label: "Reporting DB" }, { value: "manualEntry", label: "Manual Entry" }, ], }; @@ -65,7 +65,7 @@ const CippCustomDataMappingForm = ({ formControl }) => { condition: { field: "sourceType", compareType: "valueEq", - compareValue: "extensionSync", + compareValue: "reportingDb", }, }, { @@ -219,7 +219,7 @@ const CippCustomDataMappingForm = ({ formControl }) => { - {selectedSourceType?.value === "extensionSync" && ( + {selectedSourceType?.value === "reportingDb" && ( <> @@ -282,7 +282,7 @@ const CippCustomDataMappingForm = ({ formControl }) => { - {selectedExtensionSyncDataset && selectedSourceType?.value === "extensionSync" && ( + {selectedExtensionSyncDataset && selectedSourceType?.value === "reportingDb" && ( { - + {!hideTitle && ( - {!hideBackButton && ( -
- -
- )} -
diff --git a/src/components/CippFormPages/CippInviteGuest.jsx b/src/components/CippFormPages/CippInviteGuest.jsx index 7d62d071da0b..225c3282dd45 100644 --- a/src/components/CippFormPages/CippInviteGuest.jsx +++ b/src/components/CippFormPages/CippInviteGuest.jsx @@ -1,6 +1,6 @@ import "@mui/material"; import { Grid } from "@mui/system"; -import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; +import CippFormComponent from "../CippComponents/CippFormComponent"; const CippInviteUser = (props) => { const { formControl, userSettingsDefaults } = props; diff --git a/src/components/CippFormPages/CippJSONView.jsx b/src/components/CippFormPages/CippJSONView.jsx index 0215385cbc89..4f082ef9ac33 100644 --- a/src/components/CippFormPages/CippJSONView.jsx +++ b/src/components/CippFormPages/CippJSONView.jsx @@ -19,7 +19,7 @@ import { PropertyList } from "../property-list"; import { getCippTranslation } from "../../utils/get-cipp-translation"; import { getCippFormatting } from "../../utils/get-cipp-formatting"; import { CippCodeBlock } from "../CippComponents/CippCodeBlock"; -import intuneCollection from "/src/data/intuneCollection.json"; +import intuneCollection from "../../data/intuneCollection.json"; import { useGuidResolver } from "../../hooks/use-guid-resolver"; const cleanObject = (obj) => { diff --git a/src/components/CippFormPages/CippSafeLinksPolicyRuleForm.jsx b/src/components/CippFormPages/CippSafeLinksPolicyRuleForm.jsx index ce25cc269028..2937aec1ae1a 100644 --- a/src/components/CippFormPages/CippSafeLinksPolicyRuleForm.jsx +++ b/src/components/CippFormPages/CippSafeLinksPolicyRuleForm.jsx @@ -1,15 +1,15 @@ import { useEffect, useState } from "react"; import { Grid } from "@mui/system"; -import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; +import CippFormComponent from "../CippComponents/CippFormComponent"; import { Typography } from "@mui/material"; -import { CippFormUserSelector } from "/src/components/CippComponents/CippFormUserSelector"; -import { CippFormGroupSelector } from "/src/components/CippComponents/CippFormGroupSelector"; -import { CippFormDomainSelector } from "/src/components/CippComponents/CippFormDomainSelector"; -import { CippInfoCard } from "/src/components/CippCards/CippInfoCard"; +import { CippFormUserSelector } from "../CippComponents/CippFormUserSelector"; +import { CippFormGroupSelector } from "../CippComponents/CippFormGroupSelector"; +import { CippFormDomainSelector } from "../CippComponents/CippFormDomainSelector"; +import { CippInfoCard } from "../CippCards/CippInfoCard"; import { InformationCircleIcon } from "@heroicons/react/24/outline"; -import { getCippValidator } from "/src/utils/get-cipp-validator"; -import { ApiGetCall } from "/src/api/ApiCall"; -import { useSettings } from "/src/hooks/use-settings"; +import { getCippValidator } from "../../utils/get-cipp-validator"; +import { ApiGetCall } from "../../api/ApiCall"; +import { useSettings } from "../../hooks/use-settings"; // Utility functions for data processing export const safeLinksDataUtils = { diff --git a/src/components/CippFormPages/CippSchedulerForm.jsx b/src/components/CippFormPages/CippSchedulerForm.jsx index f4fa7296bb44..8857a9d427a5 100644 --- a/src/components/CippFormPages/CippSchedulerForm.jsx +++ b/src/components/CippFormPages/CippSchedulerForm.jsx @@ -14,15 +14,15 @@ import { } from "@mui/material"; import { Grid, Stack } from "@mui/system"; import { useWatch } from "react-hook-form"; -import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; -import { CippFormTenantSelector } from "/src/components/CippComponents/CippFormTenantSelector"; -import { CippFormCondition } from "/src/components/CippComponents/CippFormCondition"; -import CippGraphResourceSelector from "/src/components/CippComponents/CippGraphResourceSelector"; -import CippGraphAttributeSelector from "/src/components/CippComponents/CippGraphAttributeSelector"; -import { getCippValidator } from "/src/utils/get-cipp-validator"; +import CippFormComponent from "../CippComponents/CippFormComponent"; +import { CippFormTenantSelector } from "../CippComponents/CippFormTenantSelector"; +import { CippFormCondition } from "../CippComponents/CippFormCondition"; +import CippGraphResourceSelector from "../CippComponents/CippGraphResourceSelector"; +import CippGraphAttributeSelector from "../CippComponents/CippGraphAttributeSelector"; +import { getCippValidator } from "../../utils/get-cipp-validator"; import { useRouter } from "next/router"; import Link from "next/link"; -import { ApiGetCall, ApiPostCall } from "/src/api/ApiCall"; +import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; import { useEffect, useState } from "react"; import CippFormInputArray from "../CippComponents/CippFormInputArray"; import { CippApiResults } from "../CippComponents/CippApiResults"; @@ -100,6 +100,8 @@ const CippSchedulerForm = (props) => { { value: "0", label: "Once" }, { value: "1d", label: "Every 1 day" }, { value: "7d", label: "Every 7 days" }, + { value: "14d", label: "Every 14 days" }, + { value: "21d", label: "Every 21 days" }, { value: "30d", label: "Every 30 days" }, { value: "365d", label: "Every 365 days" }, ]; @@ -568,6 +570,16 @@ const CippSchedulerForm = (props) => { /> + + + + {/* Divider */} diff --git a/src/components/CippIntegrations/CippApiClientManagement.jsx b/src/components/CippIntegrations/CippApiClientManagement.jsx index 13cb698b8c56..829ad4413ce2 100644 --- a/src/components/CippIntegrations/CippApiClientManagement.jsx +++ b/src/components/CippIntegrations/CippApiClientManagement.jsx @@ -2,7 +2,7 @@ import { Button, Stack, SvgIcon, Menu, MenuItem, ListItemText, Alert } from "@mu import { useState } from "react"; import isEqual from "lodash/isEqual"; import { useForm } from "react-hook-form"; -import { ApiGetCall, ApiGetCallWithPagination, ApiPostCall } from "/src/api/ApiCall"; +import { ApiGetCall, ApiGetCallWithPagination, ApiPostCall } from "../../api/ApiCall"; import { CippDataTable } from "../CippTable/CippDataTable"; import { ChevronDownIcon, @@ -253,10 +253,11 @@ const CippApiClientManagement = () => { showDivider={false} isFetching={azureConfig.isFetching} /> - {azureConfig.isSuccess && ( + {azureConfig.isSuccess && apiClients.isSuccess && ( <> {!isEqual( - apiClients.data?.pages?.[0]?.Results?.filter((c) => c.Enabled) + (apiClients.data?.pages?.[0]?.Results || []) + .filter((c) => c.Enabled) .map((c) => c.ClientId) .sort(), (azureConfig.data?.Results?.ClientIDs || []).sort() @@ -264,7 +265,8 @@ const CippApiClientManagement = () => { You have unsaved changes. Click Actions > Save Azure Configuration to update - the allowed API Clients. + the allowed API Clients. If you've just saved your API clients, try refreshing the + configuration first. )} diff --git a/src/components/CippIntegrations/CippIntegrationFieldMapping.jsx b/src/components/CippIntegrations/CippIntegrationFieldMapping.jsx index ae0014d0e276..53a88787de19 100644 --- a/src/components/CippIntegrations/CippIntegrationFieldMapping.jsx +++ b/src/components/CippIntegrations/CippIntegrationFieldMapping.jsx @@ -8,13 +8,13 @@ import { Button, Alert, } from "@mui/material"; -import CippFormSection from "/src/components/CippFormPages/CippFormSection"; +import CippFormSection from "../CippFormPages/CippFormSection"; import { useForm } from "react-hook-form"; -import { ApiGetCall } from "/src/api/ApiCall"; +import { ApiGetCall } from "../../api/ApiCall"; import { useRouter } from "next/router"; -import extensions from "/src/data/Extensions.json"; +import extensions from "../../data/Extensions.json"; import React, { useEffect, useState } from "react"; -import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; +import CippFormComponent from "../CippComponents/CippFormComponent"; import { Sync } from "@mui/icons-material"; import { Stack, Grid } from "@mui/system"; diff --git a/src/components/CippIntegrations/CippIntegrationSettings.jsx b/src/components/CippIntegrations/CippIntegrationSettings.jsx index 3ff5bea6f256..d0156df3897a 100644 --- a/src/components/CippIntegrations/CippIntegrationSettings.jsx +++ b/src/components/CippIntegrations/CippIntegrationSettings.jsx @@ -1,12 +1,12 @@ import { Box, CardContent } from "@mui/material"; import { Grid } from "@mui/system"; -import CippFormSection from "/src/components/CippFormPages/CippFormSection"; -import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; +import CippFormSection from "../CippFormPages/CippFormSection"; +import CippFormComponent from "../CippComponents/CippFormComponent"; import { useForm } from "react-hook-form"; -import { useSettings } from "/src/hooks/use-settings"; -import { ApiGetCall } from "/src/api/ApiCall"; +import { useSettings } from "../../hooks/use-settings"; +import { ApiGetCall } from "../../api/ApiCall"; import { useRouter } from "next/router"; -import extensions from "/src/data/Extensions.json"; +import extensions from "../../data/Extensions.json"; import React, { useEffect } from "react"; import { CippFormCondition } from "../CippComponents/CippFormCondition"; diff --git a/src/components/CippIntegrations/CippIntegrationTenantMapping.jsx b/src/components/CippIntegrations/CippIntegrationTenantMapping.jsx index fe0039c63799..cfe214218139 100644 --- a/src/components/CippIntegrations/CippIntegrationTenantMapping.jsx +++ b/src/components/CippIntegrations/CippIntegrationTenantMapping.jsx @@ -10,11 +10,11 @@ import { Typography, } from "@mui/material"; import { Grid } from "@mui/system"; -import { useState } from "react"; +import { useState, useMemo } from "react"; import { useForm } from "react-hook-form"; -import { ApiGetCall, ApiPostCall } from "/src/api/ApiCall"; +import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; import { useRouter } from "next/router"; -import extensions from "/src/data/Extensions.json"; +import extensions from "../../data/Extensions.json"; import { useEffect } from "react"; import { CippDataTable } from "../CippTable/CippDataTable"; import { PlusSmallIcon, SparklesIcon, TrashIcon } from "@heroicons/react/24/outline"; @@ -144,6 +144,11 @@ const CippIntegrationSettings = ({ children }) => { const extension = extensions.find((extension) => extension.id === router.query.id); + // Memoize the removeOptions array to ensure it updates when tableData changes + const removedTenantIds = useMemo(() => { + return Array.isArray(tableData) ? tableData.map((item) => item.TenantId) : []; + }, [tableData]); + useEffect(() => { if (mappings.isSuccess) { setTableData(mappings.data.Mappings ?? []); @@ -173,7 +178,7 @@ const CippIntegrationSettings = ({ children }) => { multiple={false} required={false} disableClearable={false} - removeOptions={tableData.map((item) => item.TenantId)} + removeOptions={removedTenantIds} valueField="customerId" /> diff --git a/src/components/CippSettings/CippBackendCard.jsx b/src/components/CippSettings/CippBackendCard.jsx index 4438f0b813a8..6c9465ae5a90 100644 --- a/src/components/CippSettings/CippBackendCard.jsx +++ b/src/components/CippSettings/CippBackendCard.jsx @@ -1,11 +1,11 @@ import { OpenInNew } from "@mui/icons-material"; -import CippButtonCard from "/src/components/CippCards/CippButtonCard"; +import CippButtonCard from "../CippCards/CippButtonCard"; import { Button, Stack, SvgIcon, Typography } from "@mui/material"; import { CippOffCanvas } from "../CippComponents/CippOffCanvas"; import { useState } from "react"; -import { getCippTranslation } from "/src/utils/get-cipp-translation"; +import { getCippTranslation } from "../../utils/get-cipp-translation"; -export const CippBackendCard = ({ backendComponents, item }) => { +export const CippBackendCard = ({ backendComponents, item, hosted }) => { const [open, setOpen] = useState(false); const BackendButton = () => { @@ -30,7 +30,7 @@ export const CippBackendCard = ({ backendComponents, item }) => { variant="contained" size="small" onClick={() => setOpen(true)} - disabled={backendComponents.isFetching} + disabled={backendComponents.isFetching || hosted} startIcon={ item.offcanvasIcon ? {item.offcanvasIcon} : "" } diff --git a/src/components/CippSettings/CippBackupRetentionSettings.jsx b/src/components/CippSettings/CippBackupRetentionSettings.jsx new file mode 100644 index 000000000000..38cd78ed2ceb --- /dev/null +++ b/src/components/CippSettings/CippBackupRetentionSettings.jsx @@ -0,0 +1,101 @@ +import { Button, ButtonGroup, SvgIcon, Typography, TextField, Box } from "@mui/material"; +import CippButtonCard from "../CippCards/CippButtonCard"; +import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; +import { History } from "@mui/icons-material"; +import { useState, useEffect } from "react"; + +const CippBackupRetentionSettings = () => { + const retentionSetting = ApiGetCall({ + url: "/api/ExecBackupRetentionConfig?List=true", + queryKey: "BackupRetentionSettings", + }); + + const retentionChange = ApiPostCall({ + datafromUrl: true, + relatedQueryKeys: "BackupRetentionSettings", + }); + + const [retentionDays, setRetentionDays] = useState(30); + const [error, setError] = useState(""); + + useEffect(() => { + if (retentionSetting?.data?.Results?.RetentionDays) { + setRetentionDays(retentionSetting.data.Results.RetentionDays); + } + }, [retentionSetting.data]); + + const handleRetentionChange = () => { + const days = parseInt(retentionDays); + + if (isNaN(days) || days < 7) { + setError("Retention must be at least 7 days"); + return; + } + + setError(""); + retentionChange.mutate({ + url: "/api/ExecBackupRetentionConfig", + data: { RetentionDays: days }, + queryKey: "BackupRetentionPost", + }); + }; + + const handleInputChange = (e) => { + const value = e.target.value; + setRetentionDays(value); + + const days = parseInt(value); + if (!isNaN(days) && days < 7) { + setError("Retention must be at least 7 days"); + } else if (isNaN(days)) { + setError("Please enter a valid number"); + } else { + setError(""); + } + }; + + const RetentionControls = () => { + return ( + + + + + ); + }; + + return ( + } + > + + Configure how long to keep backup files. Both CIPP system backups and tenant backups will be + automatically deleted after this period. Minimum retention is 7 days, default is 30 days. + Cleanup runs daily at 2:00 AM. + + + ); +}; + +export default CippBackupRetentionSettings; diff --git a/src/components/CippSettings/CippBackupSettings.jsx b/src/components/CippSettings/CippBackupSettings.jsx index 39663651f7d2..a7f56afe7d0f 100644 --- a/src/components/CippSettings/CippBackupSettings.jsx +++ b/src/components/CippSettings/CippBackupSettings.jsx @@ -1,7 +1,7 @@ import { Button, SvgIcon, Typography } from "@mui/material"; -import CippButtonCard from "/src/components/CippCards/CippButtonCard"; -import { ApiPostCall } from "/src/api/ApiCall"; -import { useDialog } from "/src/hooks/use-dialog"; +import CippButtonCard from "../CippCards/CippButtonCard"; +import { ApiPostCall } from "../../api/ApiCall"; +import { useDialog } from "../../hooks/use-dialog"; import { SettingsBackupRestore } from "@mui/icons-material"; import Link from "next/link"; diff --git a/src/components/CippSettings/CippBrandingSettings.jsx b/src/components/CippSettings/CippBrandingSettings.jsx index a75330da986b..b0e0f747ef35 100644 --- a/src/components/CippSettings/CippBrandingSettings.jsx +++ b/src/components/CippSettings/CippBrandingSettings.jsx @@ -1,9 +1,9 @@ import { useState } from "react"; import { Button, Typography, Box, Alert } from "@mui/material"; import { Palette, Upload } from "@mui/icons-material"; -import CippButtonCard from "/src/components/CippCards/CippButtonCard"; -import { ApiGetCall, ApiPostCall } from "/src/api/ApiCall"; -import { useSettings } from "/src/hooks/use-settings"; +import CippButtonCard from "../CippCards/CippButtonCard"; +import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; +import { useSettings } from "../../hooks/use-settings"; import { CippApiResults } from "../CippComponents/CippApiResults"; import CippFormComponent from "../CippComponents/CippFormComponent"; import { useForm } from "react-hook-form"; @@ -95,7 +95,7 @@ const CippBrandingSettings = () => { return ( - - - {/* Main offcanvas */} - setOffcanvasVisible(false)} - title={`${cat}.${obj} Endpoints`} - > - - - Listed below are the available API endpoints based on permission level. - ReadWrite level includes endpoints under Read. - - {Object.keys(apiPermissions[cat][obj]).map((type, typeIndex) => { - var items = []; - for (var api in apiPermissions[cat][obj][type]) { - const apiFunction = apiPermissions[cat][obj][type][api]; - items.push({ - name: apiFunction.Name, - description: apiFunction.Description?.[0]?.Text || null - }); - } - return ( - - {type} - - {items.map((item, idx) => ( - - - {item.name} - - {item.description && ( - - )} - - ))} - - - ); - })} - - - - {/* Description offcanvas */} - setDescriptionOffcanvasVisible(false)} - title="Function Description" - > - - - {selectedDescription.name} - - - {selectedDescription.description} - - - - - ); - }; - - return ( - <> - - - - ({ - label: role.RowKey, - value: role.RowKey, - }))} - isFetching={customRoleListFetching} - refreshFunction={() => refetchCustomRoleList()} - creatable={true} - formControl={formControl} - multiple={false} - fullWidth={true} - /> - {cippApiRoleSelected && ( - - This is the default role for all API clients in the CIPP-API integration. If you - would like different permissions for specific applications, create a role per - application and select it from the CIPP-API integrations page. - - )} - - - - {allTenantSelected && blockedTenants?.length == 0 && ( - - All tenants selected, no tenant restrictions will be applied unless blocked tenants - are specified. - - )} - - {allTenantSelected && ( - - - - )} - - {currentRole && ( - <> - {apiPermissionFetching && } - {apiPermissionSuccess && ( - <> - API Permissions - - Set All Permissions - - - - - - - <> - {Object.keys(apiPermissions) - .sort() - .map((cat, catIndex) => ( - - }> - {cat} - - - {Object.keys(apiPermissions[cat]) - .sort() - .map((obj, index) => { - return ( - - - - ); - })} - - - ))} - - - - )} - - )} - - - - {selectedRole && selectedTenant?.length > 0 && ( - <> -
Allowed Tenants
-
    - {selectedTenant.map((tenant, idx) => ( -
  • {tenant?.label}
  • - ))} -
- - )} - {selectedRole && blockedTenants?.length > 0 && ( - <> -
Blocked Tenants
-
    - {blockedTenants.map((tenant, idx) => ( -
  • {tenant?.label}
  • - ))} -
- - )} - {selectedRole && selectedPermissions && ( - <> -
Selected Permissions
-
    - {selectedPermissions && - Object.keys(selectedPermissions) - ?.sort() - .map((cat, idx) => ( - <> - {selectedPermissions?.[cat] && - !selectedPermissions?.[cat]?.includes("None") && ( -
  • {selectedPermissions[cat]}
  • - )} - - ))} -
- - )} -
-
- - - - - {currentRole && ( - - )} - - - - ); -}; - -export default CippCustomRoles; diff --git a/src/components/CippSettings/CippDnsSettings.jsx b/src/components/CippSettings/CippDnsSettings.jsx index 847c0451bf87..666337169266 100644 --- a/src/components/CippSettings/CippDnsSettings.jsx +++ b/src/components/CippSettings/CippDnsSettings.jsx @@ -1,6 +1,6 @@ import { Button, ButtonGroup, SvgIcon, Typography } from "@mui/material"; -import CippButtonCard from "/src/components/CippCards/CippButtonCard"; -import { ApiGetCall, ApiPostCall } from "/src/api/ApiCall"; +import CippButtonCard from "../CippCards/CippButtonCard"; +import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; import { Dns } from "@mui/icons-material"; const CippDnsSettings = () => { @@ -23,7 +23,7 @@ const CippDnsSettings = () => { }; const DnsButtons = () => { - const resolvers = ["Google", "Cloudflare", "Quad9"]; + const resolvers = ["Google", "Cloudflare"]; return resolvers.map((resolver, index) => ( + } + > + + + Configure maximum allowed duration for Just-In-Time (JIT) admin accounts. This setting + helps enforce security policies by preventing technicians from creating JIT admin accounts + with excessively long lifespans. + + + {/* Maximum Duration Section */} + + + Maximum Duration + + { + // Allow empty value (no limit) + if (!value || typeof value !== "string" || value.trim() === "") { + return true; + } + const iso8601Regex = + /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/; + if (!iso8601Regex.test(value)) { + return "Invalid format. Use PT1H, P1D, P7D, P28D, etc."; + } + return true; + }, + }, + }} + formControl={formControl} + /> + + + + + Leave empty for no limit on JIT admin account duration. When set, technicians cannot + create JIT admin accounts with durations exceeding this limit. This setting applies + globally to all tenants. + + + + {/* API Results */} + + +
+ ); +}; + +export default CippJitAdminSettings; diff --git a/src/components/CippSettings/CippPasswordSettings.jsx b/src/components/CippSettings/CippPasswordSettings.jsx index 0f3a8d6f4976..1394beff3fe5 100644 --- a/src/components/CippSettings/CippPasswordSettings.jsx +++ b/src/components/CippSettings/CippPasswordSettings.jsx @@ -1,6 +1,6 @@ import { Button, ButtonGroup, SvgIcon, Typography } from "@mui/material"; -import CippButtonCard from "/src/components/CippCards/CippButtonCard"; -import { ApiGetCall, ApiPostCall } from "/src/api/ApiCall"; +import CippButtonCard from "../CippCards/CippButtonCard"; +import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; import { KeyIcon } from "@heroicons/react/24/outline"; const CippPasswordSettings = () => { diff --git a/src/components/CippSettings/CippPermissionCheck.jsx b/src/components/CippSettings/CippPermissionCheck.jsx index db0c81c7ad38..c38f8aa49d5a 100644 --- a/src/components/CippSettings/CippPermissionCheck.jsx +++ b/src/components/CippSettings/CippPermissionCheck.jsx @@ -9,8 +9,8 @@ import { SvgIcon, Typography, } from "@mui/material"; -import CippButtonCard from "/src/components/CippCards/CippButtonCard"; -import { ApiGetCall } from "/src/api/ApiCall"; +import CippButtonCard from "../CippCards/CippButtonCard"; +import { ApiGetCall } from "../../api/ApiCall"; import { useEffect, useState } from "react"; import { CippPermissionResults } from "./CippPermissionResults"; import { CippGDAPResults } from "./CippGDAPResults"; diff --git a/src/components/CippSettings/CippPermissionResults.jsx b/src/components/CippSettings/CippPermissionResults.jsx index 22c0b3d425c4..375dca112f2a 100644 --- a/src/components/CippSettings/CippPermissionResults.jsx +++ b/src/components/CippSettings/CippPermissionResults.jsx @@ -1,10 +1,10 @@ import { Button, Link, List, ListItem, Skeleton, SvgIcon, Typography } from "@mui/material"; import { Cancel, CheckCircle } from "@mui/icons-material"; -import { CippPropertyList } from "/src/components/CippComponents/CippPropertyList"; +import { CippPropertyList } from "../CippComponents/CippPropertyList"; import { WrenchIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { CippOffCanvas } from "../CippComponents/CippOffCanvas"; import { CippPropertyListCard } from "../CippCards/CippPropertyListCard"; -import { CippDataTable } from "/src/components/CippTable/CippDataTable"; +import { CippDataTable } from "../CippTable/CippDataTable"; import { ApiPostCall } from "../../api/ApiCall"; import { CippApiResults } from "../CippComponents/CippApiResults"; import { useEffect, useState } from "react"; @@ -143,9 +143,6 @@ export const CippPermissionResults = (props) => { }} extendedInfo={[]} > - - Permission Details - {results?.Results?.Links.length > 0 && ( { const setDefaults = useWatch({ control: formControl.control, name: "Defaults" }); const selectedPermissions = useWatch({ control: formControl.control, name: "Permissions" }); const selectedEntraGroup = useWatch({ control: formControl.control, name: "EntraGroup" }); + const ipRanges = useWatch({ control: formControl.control, name: "IPRange" }); const { data: apiPermissions = [], @@ -87,7 +88,11 @@ export const CippRoleAddEdit = ({ selectedRole }) => { queryKey: "customRoleList", }); - const { data: { pages = [] } = {}, isSuccess: tenantsSuccess } = ApiGetCallWithPagination({ + const { + data: { pages = [] } = {}, + isSuccess: tenantsSuccess, + isFetching: tenantsFetching, + } = ApiGetCallWithPagination({ url: "/api/ListTenants?AllTenantSelector=true", queryKey: "ListTenants-All", }); @@ -240,6 +245,13 @@ export const CippRoleAddEdit = ({ selectedRole }) => { value: endpoint, })) || []; + // Process IP ranges + const processedIPRanges = + currentPermissions?.IPRange?.map((ip) => ({ + label: ip, + value: ip, + })) || []; + formControl.reset({ Permissions: basePermissions && Object.keys(basePermissions).length > 0 @@ -249,6 +261,7 @@ export const CippRoleAddEdit = ({ selectedRole }) => { allowedTenants: newAllowedTenants, blockedTenants: newBlockedTenants, BlockedEndpoints: processedBlockedEndpoints, + IPRange: processedIPRanges, EntraGroup: currentPermissions?.EntraGroup, }); } @@ -340,6 +353,11 @@ export const CippRoleAddEdit = ({ selectedRole }) => { return endpoint.value || endpoint; }) || []; + const processedIPRanges = + ipRanges?.map((ip) => { + return ip?.value || ip; + }) || []; + updatePermissions.mutate({ url: "/api/ExecCustomRole?Action=AddUpdate", data: { @@ -349,6 +367,7 @@ export const CippRoleAddEdit = ({ selectedRole }) => { AllowedTenants: processedAllowedTenants, BlockedTenants: processedBlockedTenants, BlockedEndpoints: processedBlockedEndpoints, + IPRange: processedIPRanges, }, }); }; @@ -509,6 +528,7 @@ export const CippRoleAddEdit = ({ selectedRole }) => { dataKey: "Results", labelField: "displayName", valueField: "id", + showRefresh: true, }} formControl={formControl} fullWidth={true} @@ -612,6 +632,26 @@ export const CippRoleAddEdit = ({ selectedRole }) => { )} + + + {apiPermissionFetching && ( <> @@ -821,6 +861,16 @@ export const CippRoleAddEdit = ({ selectedRole }) => { )} + {ipRanges?.length > 0 && ( + <> +
Allowed IP Ranges
+
    + {ipRanges.map((ip, idx) => ( +
  • {ip?.value || ip?.label || ip}
  • + ))} +
+ + )} {selectedPermissions && apiPermissionSuccess && ( <>
Selected Permissions
@@ -849,7 +899,13 @@ export const CippRoleAddEdit = ({ selectedRole }) => { className="me-2" type="submit" variant="contained" - disabled={updatePermissions.isPending || customRoleListFetching || !formState.isValid} + disabled={ + updatePermissions.isPending || + customRoleListFetching || + apiPermissionFetching || + tenantsFetching || + !formState.isValid + } startIcon={ diff --git a/src/components/CippSettings/CippRoles.jsx b/src/components/CippSettings/CippRoles.jsx index c155064b634a..34b1f08dcd78 100644 --- a/src/components/CippSettings/CippRoles.jsx +++ b/src/components/CippSettings/CippRoles.jsx @@ -44,7 +44,7 @@ const CippRoles = () => { disableVariables: true, }, ], - relatedQueryKeys: ["customRoleList"], + relatedQueryKeys: ["customRoleList", "customRoleTable"], confirmText: "Are you sure you want to clone this custom role?", condition: (row) => row?.Type === "Custom", }, @@ -63,7 +63,7 @@ const CippRoles = () => { RoleName: "RoleName", }, condition: (row) => row?.Type === "Custom", - relatedQueryKeys: ["customRoleList"], + relatedQueryKeys: ["customRoleList", "customRoleTable"], }, ]; diff --git a/src/components/CippSettings/CippVersionProperties.jsx b/src/components/CippSettings/CippVersionProperties.jsx index 6fbf88a39311..4b19e1d9e328 100644 --- a/src/components/CippSettings/CippVersionProperties.jsx +++ b/src/components/CippSettings/CippVersionProperties.jsx @@ -1,7 +1,7 @@ import { Box, Button, SvgIcon } from "@mui/material"; -import { CippPropertyListCard } from "/src/components/CippCards/CippPropertyListCard"; +import { CippPropertyListCard } from "../CippCards/CippPropertyListCard"; import { CheckCircle, SystemUpdateAlt, Warning } from "@mui/icons-material"; -import { ApiGetCall } from "/src/api/ApiCall"; +import { ApiGetCall } from "../../api/ApiCall"; import { useEffect } from "react"; const CippVersionProperties = () => { diff --git a/src/components/CippStandards/CippStandardAccordion.jsx b/src/components/CippStandards/CippStandardAccordion.jsx index aec6d0fbda94..dd59cee0bccc 100644 --- a/src/components/CippStandards/CippStandardAccordion.jsx +++ b/src/components/CippStandards/CippStandardAccordion.jsx @@ -29,17 +29,17 @@ import { Construction, } from "@mui/icons-material"; import { Grid } from "@mui/system"; -import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; -import { useWatch } from "react-hook-form"; +import CippFormComponent from "../CippComponents/CippFormComponent"; +import { useWatch, useFormState } from "react-hook-form"; import _ from "lodash"; import Microsoft from "../../icons/iconly/bulk/microsoft"; import Azure from "../../icons/iconly/bulk/azure"; import Exchange from "../../icons/iconly/bulk/exchange"; import Defender from "../../icons/iconly/bulk/defender"; import Intune from "../../icons/iconly/bulk/intune"; -import GDAPRoles from "/src/data/GDAPRoles"; -import timezoneList from "/src/data/timezoneList"; -import standards from "/src/data/standards.json"; +import GDAPRoles from "../../data/GDAPRoles"; +import timezoneList from "../../data/timezoneList"; +import standards from "../../data/standards.json"; import { CippFormCondition } from "../CippComponents/CippFormCondition"; import { CippPolicyImportDrawer } from "../CippComponents/CippPolicyImportDrawer"; import ReactMarkdown from "react-markdown"; @@ -108,6 +108,8 @@ const CippStandardAccordion = ({ control: formControl.control, }); + const { errors: formErrors } = useFormState({ control: formControl.control }); + // Watch all trackDrift values for all standards at once const allTrackDriftValues = useWatch({ control: formControl.control, @@ -568,19 +570,19 @@ const CippStandardAccordion = ({ if (templateList && templateList.label) { templateDisplayName = templateList.label; } - + // Check for TemplateList-Tags selection (takes priority) const templateListTags = _.get(watchedValues, `${standardName}.TemplateList-Tags`); if (templateListTags && templateListTags.label) { templateDisplayName = templateListTags.label; } } - + // For multiple standards, check the first added component const selectedTemplateName = standard.multiple ? _.get(watchedValues, `${standardName}.${standard.addedComponent?.[0]?.name}`) : ""; - + // Build accordion title with template name if available const accordionTitle = templateDisplayName ? `${standard.label} - ${templateDisplayName}` @@ -674,11 +676,16 @@ const CippStandardAccordion = ({ const hasAction = actionValue && (!Array.isArray(actionValue) || actionValue.length > 0); + // Check if this standard has any validation errors + const standardErrors = _.get(formErrors, standardName); + const hasValidationErrors = standardErrors && Object.keys(standardErrors).length > 0; + // Allow saving if: // 1. Action is selected if required // 2. All required fields are filled // 3. There are unsaved changes - const canSave = hasAction && requiredFieldsFilled && hasUnsaved; + // 4. No validation errors + const canSave = hasAction && requiredFieldsFilled && hasUnsaved && !hasValidationErrors; return ( @@ -707,6 +714,14 @@ const CippStandardAccordion = ({ {accordionTitle} + {standard.deprecated && ( + + )} {/* Hide action chips in drift mode */} {!isDriftMode && selectedActions && selectedActions?.length > 0 && ( <> @@ -773,10 +788,21 @@ const CippStandardAccordion = ({ {standard.multiple && ( - - handleAddMultipleStandard(standardName)}> - - + + + handleAddMultipleStandard(standardName)} + disabled={standard.deprecated} + > + + + )} - + {standard.deprecated && ( + + + ⚠️ This standard is deprecated and cannot be configured. Please remove it + from your template and use an alternative standard if available. + + + )} + {isDriftMode ? ( /* Drift mode layout - full width with slider first */ diff --git a/src/components/CippStandards/CippStandardDialog.jsx b/src/components/CippStandards/CippStandardDialog.jsx index d74d6f4d3630..6873936d9cda 100644 --- a/src/components/CippStandards/CippStandardDialog.jsx +++ b/src/components/CippStandards/CippStandardDialog.jsx @@ -102,9 +102,6 @@ const StandardCard = memo( height: "100%", display: "flex", flexDirection: "column", - ...(isNewStandard(standard.addedDate) && { - mt: 1.2, // Add top margin to accommodate the "New" label - }), }} > {isNewStandard(standard.addedDate) && ( @@ -123,6 +120,22 @@ const StandardCard = memo( }} /> )} + {standard.deprecated && ( + + )} @@ -243,7 +262,34 @@ const StandardCard = memo( - {standard.multiple ? ( + {standard.deprecated ? ( + + + } + label={ + isSelected + ? "Remove this standard from the template" + : "This standard is deprecated" + } + /> + {!isSelected && ( + + This standard is deprecated and cannot be added. Please use an alternative + standard if available. + + )} + + ) : standard.multiple ? ( } label="Add this standard to the template" @@ -329,7 +376,7 @@ const VirtualizedStandardGrid = memo(({ items, renderItem }) => { overscan={5} defaultItemHeight={320} // Provide estimated row height for better virtualization itemContent={(index) => ( - + {standard.label}
+ {standard.deprecated && ( + + )} {isNewStandard(standard.addedDate) && ( - {standard.multiple ? ( + {standard.deprecated ? ( + isSelected ? ( + + } + label="Remove" + sx={{ mr: 1 }} + /> + ) : ( + + Deprecated - Cannot be added + + ) + ) : standard.multiple ? ( ({ - GUID: t.GUID, - standardId: t.standardId, - standardName: t.standardName, - })), - }); - const existingTemplates = driftValidationApi.data.filter((template) => { const shouldInclude = edit && watchForm.GUID ? template.standardId !== watchForm.GUID : true; - console.log( - `Template ${template.standardId} (${template.standardName}): shouldInclude=${shouldInclude}, currentGUID=${watchForm.GUID}` - ); return shouldInclude; }); - console.log( - "Filtered templates:", - existingTemplates?.map((t) => ({ - GUID: t.GUID, - standardId: t.standardId, - standardName: t.standardName, - })) - ); - // Get tenant groups data const groups = tenantGroupsApi.data?.Results || []; @@ -198,45 +176,27 @@ const CippStandardsSideBar = ({ }); // Check for conflicts with unique templates - console.log("Checking conflicts with unique templates:", uniqueTemplates); - console.log("Selected tenant list:", selectedTenantList); - for (const templateId in uniqueTemplates) { const template = uniqueTemplates[templateId]; const templateTenants = template.tenants; - console.log( - `Checking template ${templateId} (${template.standardName}) with tenants:`, - templateTenants - ); - const hasConflict = selectedTenantList.some((selectedTenant) => { // Check if any template tenant matches the selected tenant const conflict = templateTenants.some((templateTenant) => { if (selectedTenant === "AllTenants" || templateTenant === "AllTenants") { - console.log( - `Conflict found: ${selectedTenant} vs ${templateTenant} (AllTenants match)` - ); return true; } const match = selectedTenant === templateTenant; - if (match) { - console.log(`Conflict found: ${selectedTenant} vs ${templateTenant} (exact match)`); - } return match; }); return conflict; }); - console.log(`Template ${templateId} has conflict: ${hasConflict}`); - if (hasConflict) { conflicts.push(template.standardName || "Unknown Template"); } } - console.log("Final conflicts:", conflicts); - if (conflicts.length > 0) { setDriftError( `This template has tenants that are assigned to another Drift Template. You can only assign one Drift Template to each tenant. Please check the ${conflicts.join( @@ -420,7 +380,8 @@ const CippStandardsSideBar = ({ {(watchForm.tenantFilter?.some( (tenant) => tenant.value === "AllTenants" || tenant.type === "Group" - ) || (watchForm.excludedTenants && watchForm.excludedTenants.length > 0)) && ( + ) || + (watchForm.excludedTenants && watchForm.excludedTenants.length > 0)) && ( <> + + + When enabled, all drift alert notifications (email, webhook, and PSA) will be + disabled. + )} {/* Hide schedule options in drift mode */} @@ -539,7 +516,7 @@ const CippStandardsSideBar = ({ title="Add Standard" api={{ confirmText: isDriftMode - ? "This template will automatically every hour to detect drift. Are you sure you want to apply this Drift Template?" + ? "This template will automatically every 12 hours to detect drift. Are you sure you want to apply this Drift Template?" : watchForm.runManually ? "Are you sure you want to apply this standard? This template has been set to never run on a schedule. After saving the template you will have to run it manually." : "Are you sure you want to apply this standard? This will apply the template and run every 3 hours.", @@ -561,6 +538,7 @@ const CippStandardsSideBar = ({ type: "drift", driftAlertWebhook: "driftAlertWebhook", driftAlertEmail: "driftAlertEmail", + driftAlertDisableEmail: "driftAlertDisableEmail", } : {}), }, diff --git a/src/components/CippTable/CIPPTableToptoolbar.js b/src/components/CippTable/CIPPTableToptoolbar.js index d393c882d714..32c28c31e84a 100644 --- a/src/components/CippTable/CIPPTableToptoolbar.js +++ b/src/components/CippTable/CIPPTableToptoolbar.js @@ -49,7 +49,7 @@ import { useRouter } from "next/router"; import { CippOffCanvas } from "../CippComponents/CippOffCanvas"; import { CippCodeBlock } from "../CippComponents/CippCodeBlock"; import { ApiGetCall } from "../../api/ApiCall"; -import GraphExplorerPresets from "/src/data/GraphExplorerPresets.json"; +import GraphExplorerPresets from "../../data/GraphExplorerPresets.json"; import CippGraphExplorerFilter from "./CippGraphExplorerFilter"; import { Stack } from "@mui/system"; @@ -182,6 +182,10 @@ export const CIPPTableToptoolbar = ({ const [activeFilterName, setActiveFilterName] = useState(null); const pageName = router.pathname.split("/").slice(1).join("/"); const currentTenant = settings?.currentTenant; + const [useCompactMode, setUseCompactMode] = useState(false); + const toolbarRef = useRef(null); + const leftContainerRef = useRef(null); + const actionsContainerRef = useRef(null); const getBulkActions = (actions, selectedRows) => { return ( @@ -336,6 +340,39 @@ export const CIPPTableToptoolbar = ({ restoredFiltersRef.current.clear(); }, [pageName]); + // Detect overflow and switch to compact mode + useEffect(() => { + const checkOverflow = () => { + if (!leftContainerRef.current || !actionsContainerRef.current) { + return; + } + + const leftContainerWidth = leftContainerRef.current.offsetWidth; + const leftContainerScrollWidth = leftContainerRef.current.scrollWidth; + const actionsWidth = actionsContainerRef.current.scrollWidth; + const isOverflowing = leftContainerScrollWidth > leftContainerWidth; + const shouldBeCompact = isOverflowing || actionsWidth > leftContainerWidth * 0.6; // Actions taking > 60% of left container + + setUseCompactMode(shouldBeCompact); + }; + + // Check immediately on mount and when dependencies change + checkOverflow(); + + // Also check after a brief delay to ensure elements are fully rendered + const timeoutId = setTimeout(checkOverflow, 100); + + const resizeObserver = new ResizeObserver(checkOverflow); + if (leftContainerRef.current) { + resizeObserver.observe(leftContainerRef.current); + } + + return () => { + clearTimeout(timeoutId); + resizeObserver.disconnect(); + }; + }, [hasSelection, customBulkActions.length, exportEnabled, filters?.length, usedColumns?.length]); + // Restore last used filter on mount if persistFilters is enabled (non-graph filters) useEffect(() => { // Wait for table to be initialized and data to be available @@ -593,6 +630,7 @@ export const CIPPTableToptoolbar = ({ return ( <> {/* Left side - Main controls */} @@ -675,9 +714,22 @@ export const CIPPTableToptoolbar = ({ /> - {/* Desktop Buttons */} + {/* Desktop Buttons - always render for measurement, hide when in compact mode */} {!mdDown && ( - <> + {/* Filters Button */} } @@ -811,7 +863,17 @@ export const CIPPTableToptoolbar = ({ Export )} - + + )} + + {/* Mobile/Compact Action Button */} + {(mdDown || useCompactMode) && !hasSelection && ( + setActionMenuAnchor(event.currentTarget)} + sx={{ flexShrink: 0 }} + > + + )} {/* Mobile Action Menu */} @@ -1065,24 +1127,6 @@ export const CIPPTableToptoolbar = ({ )} - - {/* Mobile Action Menu */} - {mdDown && ( - setActionMenuAnchor(event.currentTarget)} - size="small" - sx={{ - height: "40px", - width: "40px", - border: "1px solid", - borderColor: "divider", - borderRadius: "8px", - ml: "auto", - }} - > - - - )} {/* Right side - Additional controls */} @@ -1249,7 +1293,6 @@ export const CIPPTableToptoolbar = ({ }} > - API Response )} diff --git a/src/components/CippTable/CippDataTable.js b/src/components/CippTable/CippDataTable.js index 5a1590ac1fde..798adb751ba6 100644 --- a/src/components/CippTable/CippDataTable.js +++ b/src/components/CippTable/CippDataTable.js @@ -97,6 +97,7 @@ export const CippDataTable = (props) => { simple = false, cardButton, offCanvas = false, + offCanvasOnRowClick = false, noCard = false, hideTitle = false, refreshFunction, @@ -108,12 +109,29 @@ export const CippDataTable = (props) => { isInDialog = false, showBulkExportAction = true, } = props; + + // Create a map of column IDs to their filterType for quick lookup + const filterTypeMap = useMemo(() => { + if (!filters || !Array.isArray(filters)) return {}; + return filters.reduce((acc, filter) => { + if (filter.value && Array.isArray(filter.value)) { + filter.value.forEach((v) => { + if (v.id && filter.filterType) { + acc[v.id] = filter.filterType; + } + }); + } + return acc; + }, {}); + }, [filters]); const [columnVisibility, setColumnVisibility] = useState(initialColumnVisibility); const [configuredSimpleColumns, setConfiguredSimpleColumns] = useState(simpleColumns); const [usedData, setUsedData] = useState(data); const [usedColumns, setUsedColumns] = useState([]); const [offcanvasVisible, setOffcanvasVisible] = useState(false); const [offCanvasData, setOffCanvasData] = useState({}); + const [offCanvasRowIndex, setOffCanvasRowIndex] = useState(0); + const [filteredRows, setFilteredRows] = useState([]); const [customComponentData, setCustomComponentData] = useState({}); const [customComponentVisible, setCustomComponentVisible] = useState(false); const [actionData, setActionData] = useState({ data: {}, action: {}, ready: false }); @@ -134,7 +152,19 @@ export const CippDataTable = (props) => { useEffect(() => { if (filters && Array.isArray(filters) && filters.length > 0) { - setColumnFilters(filters); + // Process filters to add filterFn based on filterType + const processedFilters = filters.map((filter) => { + if (filter.filterType === "equal") { + // Use exact match for equal filterType + return { + ...filter, + value: Array.isArray(filter.value) ? filter.value : [filter.value], + }; + } + // Default to substring matching (backwards compatible) + return filter; + }); + setColumnFilters(processedFilters); } }, [filters]); @@ -184,19 +214,31 @@ export const CippDataTable = (props) => { return; } const apiColumns = utilColumnsFromAPI(usedData); + + // Apply custom filterFn to columns that have filterType === 'equal' + const enhancedApiColumns = apiColumns.map((col) => { + if (filterTypeMap[col.id] === "equal") { + return { + ...col, + filterFn: "equals", + }; + } + return col; + }); + let finalColumns = []; let newVisibility = { ...columnVisibility }; // Check if we're in AllTenants mode and data has Tenant property const isAllTenants = settings?.currentTenant === "AllTenants"; const hasTenantProperty = usedData.some( - (row) => row && typeof row === "object" && "Tenant" in row + (row) => row && typeof row === "object" && "Tenant" in row, ); const shouldShowTenant = isAllTenants && hasTenantProperty; if (columns.length === 0 && configuredSimpleColumns.length === 0) { - finalColumns = apiColumns; - apiColumns.forEach((col) => { + finalColumns = enhancedApiColumns; + enhancedApiColumns.forEach((col) => { newVisibility[col.id] = true; }); } else if (configuredSimpleColumns.length > 0) { @@ -209,13 +251,16 @@ export const CippDataTable = (props) => { finalResolvedColumns = [...resolvedSimpleColumns, "Tenant"]; } - finalColumns = apiColumns; + finalColumns = enhancedApiColumns; finalColumns.forEach((col) => { newVisibility[col.id] = finalResolvedColumns.includes(col.id); }); } else { const providedColumnKeys = new Set(columns.map((col) => col.id || col.header)); - finalColumns = [...columns, ...apiColumns.filter((col) => !providedColumnKeys.has(col.id))]; + finalColumns = [ + ...columns, + ...enhancedApiColumns.filter((col) => !providedColumnKeys.has(col.id)), + ]; finalColumns.forEach((col) => { newVisibility[col.accessorKey] = providedColumnKeys.has(col.id); }); @@ -234,7 +279,7 @@ export const CippDataTable = (props) => { } setUsedColumns(finalColumns); setColumnVisibility(newVisibility); - }, [columns.length, usedData, queryKey, settings?.currentTenant]); + }, [columns.length, usedData, queryKey, settings?.currentTenant, filterTypeMap]); const createDialog = useDialog(); @@ -247,8 +292,8 @@ export const CippDataTable = (props) => { configuredSimpleColumns, offCanvas, onChange, - maxHeightOffset - ) + maxHeightOffset, + ), ); //create memoized version of usedColumns, and usedData const memoizedColumns = useMemo(() => usedColumns, [usedColumns]); @@ -280,12 +325,48 @@ export const CippDataTable = (props) => { baseBackgroundColor: theme.palette.background.paper, }), muiTablePaperProps: ({ table }) => ({ - //not sx - style: { - zIndex: table.getState().isFullScreen ? 1000 : undefined, - top: table.getState().isFullScreen ? 64 : undefined, + sx: { + ...(table.getState().isFullScreen && { + position: "fixed !important", + top: "64px !important", + bottom: "0 !important", + left: { + xs: "0 !important", + lg: settings?.sidebarCollapse ? "73px !important" : "270px !important", + }, + right: "0 !important", + zIndex: "1300 !important", + m: "0 !important", + p: "16px !important", + overflow: "auto", + bgcolor: "background.paper", + maxWidth: "none !important", + width: "auto !important", + height: "auto !important", + }), }, }), + muiTableBodyRowProps: + offCanvasOnRowClick && offCanvas + ? ({ row }) => ({ + onClick: () => { + setOffCanvasData(row.original); + // Find the index of this row in the filtered rows + const filteredRowsArray = table.getFilteredRowModel().rows; + const indexInFiltered = filteredRowsArray.findIndex( + (r) => r.original === row.original, + ); + setOffCanvasRowIndex(indexInFiltered >= 0 ? indexInFiltered : 0); + setOffcanvasVisible(true); + }, + sx: { + cursor: "pointer", + "&:hover": { + backgroundColor: "action.hover", + }, + }, + }) + : undefined, // Add global styles to target the specific filter components enableColumnFilterModes: true, muiTableHeadCellProps: { @@ -370,8 +451,8 @@ export const CippDataTable = (props) => { showSkeletons: getRequestData.isFetchingNextPage ? false : getRequestData.isFetching - ? getRequestData.isFetching - : isFetching, + ? getRequestData.isFetching + : isFetching, }, onSortingChange: (newSorting) => { setSorting(newSorting ?? []); @@ -437,6 +518,12 @@ export const CippDataTable = (props) => { onClick={() => { closeMenu(); setOffCanvasData(row.original); + // Find the index of this row in the filtered rows + const filteredRowsArray = table.getFilteredRowModel().rows; + const indexInFiltered = filteredRowsArray.findIndex( + (r) => r.original === row.original, + ); + setOffCanvasRowIndex(indexInFiltered >= 0 ? indexInFiltered : 0); setOffcanvasVisible(true); }} > @@ -452,6 +539,12 @@ export const CippDataTable = (props) => { onClick={() => { closeMenu(); setOffCanvasData(row.original); + // Find the index of this row in the filtered rows + const filteredRowsArray = table.getFilteredRowModel().rows; + const indexInFiltered = filteredRowsArray.findIndex( + (r) => r.original === row.original, + ); + setOffCanvasRowIndex(indexInFiltered >= 0 ? indexInFiltered : 0); setOffcanvasVisible(true); }} > @@ -668,6 +761,19 @@ export const CippDataTable = (props) => { } }, [table.getSelectedRowModel().rows]); + useEffect(() => { + // Update filtered rows whenever table filtering/sorting changes + if (table && table.getFilteredRowModel) { + const rows = table.getFilteredRowModel().rows; + setFilteredRows(rows.map((row) => row.original)); + } + }, [ + table, + table.getState().columnFilters, + table.getState().globalFilter, + table.getState().sorting, + ]); + useEffect(() => { //check if the simplecolumns are an array, if (Array.isArray(simpleColumns) && simpleColumns.length > 0) { @@ -742,8 +848,27 @@ export const CippDataTable = (props) => { extendedData={offCanvasData} extendedInfoFields={offCanvas?.extendedInfoFields} actions={actions} - children={offCanvas?.children} + title={offCanvasData?.Name || offCanvas?.title || "Extended Info"} + children={ + offCanvas?.children ? (row) => offCanvas.children(row, offCanvasRowIndex) : undefined + } customComponent={offCanvas?.customComponent} + onNavigateUp={() => { + const newIndex = offCanvasRowIndex - 1; + if (newIndex >= 0 && filteredRows && filteredRows[newIndex]) { + setOffCanvasRowIndex(newIndex); + setOffCanvasData(filteredRows[newIndex]); + } + }} + onNavigateDown={() => { + const newIndex = offCanvasRowIndex + 1; + if (filteredRows && newIndex < filteredRows.length) { + setOffCanvasRowIndex(newIndex); + setOffCanvasData(filteredRows[newIndex]); + } + }} + canNavigateUp={offCanvasRowIndex > 0} + canNavigateDown={filteredRows && offCanvasRowIndex < filteredRows.length - 1} {...offCanvas} /> {/* Render custom component */} diff --git a/src/components/CippTable/CippDiagnosticsFilter.js b/src/components/CippTable/CippDiagnosticsFilter.js new file mode 100644 index 000000000000..e8118a10e090 --- /dev/null +++ b/src/components/CippTable/CippDiagnosticsFilter.js @@ -0,0 +1,305 @@ +import { useState, useEffect } from "react"; +import { useForm, useWatch } from "react-hook-form"; +import { + Box, + Button, + Stack, + Alert, + AlertTitle, + Accordion, + AccordionSummary, + AccordionDetails, + Typography, + IconButton, + Tooltip, + CircularProgress, +} from "@mui/material"; +import { ExpandMore, Search, Save, Delete } from "@mui/icons-material"; +import { CippFormComponent } from "../CippComponents/CippFormComponent"; +import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; +import { Grid } from "@mui/system"; +import defaultPresets from "../../data/DiagnosticsPresets.json"; + +const CippDiagnosticsFilter = ({ onSubmitFilter }) => { + const [expanded, setExpanded] = useState(true); + const [selectedPreset, setSelectedPreset] = useState(null); + const [presetOptions, setPresetOptions] = useState([]); + + const formControl = useForm({ + mode: "onChange", + defaultValues: { + presetName: "", + queryPreset: null, + query: "", + }, + }); + + const { handleSubmit } = formControl; + const queryValue = useWatch({ control: formControl.control, name: "query" }); + const queryPreset = useWatch({ control: formControl.control, name: "queryPreset" }); + const presetName = useWatch({ control: formControl.control, name: "presetName" }); + + // Load presets + const presetList = ApiGetCall({ + url: "/api/ListDiagnosticsPresets", + queryKey: "ListDiagnosticsPresets", + }); + + useEffect(() => { + // Combine built-in presets with custom presets + const builtInOptions = defaultPresets.map((preset) => ({ + label: preset.name, + value: preset.id, + query: preset.query, + columns: preset.columns || null, + isBuiltin: true, + })); + + const customOptions = + presetList.isSuccess && presetList.data + ? presetList.data.map((preset) => ({ + label: preset.name, + value: preset.GUID, + query: preset.query, + isBuiltin: false, + })) + : []; + + setPresetOptions([...builtInOptions, ...customOptions]); + }, [presetList.isSuccess, presetList.data]); + + // Load preset when selected + useEffect(() => { + if (queryPreset) { + // queryPreset is the full object from autoComplete + // Check if it's an array (multiple) or object (single) + const preset = Array.isArray(queryPreset) ? queryPreset[0] : queryPreset; + + if (preset?.query) { + formControl.setValue("query", preset.query); + formControl.setValue("presetName", preset.label); + setSelectedPreset(preset); + // Clear the preset selection so user can edit freely + formControl.setValue("queryPreset", null); + } + } + }, [queryPreset, formControl]); + + // Clear selectedPreset when query is manually edited (unless preset is custom or has no columns) + useEffect(() => { + if (selectedPreset && queryValue !== selectedPreset.query) { + // Only clear if preset is built-in and has columns defined + if (selectedPreset.isBuiltin && selectedPreset.columns) { + setSelectedPreset(null); + } + } + }, [queryValue, selectedPreset]); + + const savePresetApi = ApiPostCall({ + relatedQueryKeys: ["ListDiagnosticsPresets"], + }); + + const deletePresetApi = ApiPostCall({ + relatedQueryKeys: ["ListDiagnosticsPresets"], + }); + + const handleSavePreset = () => { + if (!presetName || !queryValue) { + return; + } + + // Built-in presets get saved as new custom presets (no GUID = new preset) + // Custom presets can be updated (include GUID) + const presetData = { + name: presetName, + query: queryValue, + GUID: selectedPreset?.isBuiltin ? undefined : selectedPreset?.value || undefined, + }; + + const isUpdate = selectedPreset && !selectedPreset.isBuiltin; + + savePresetApi.mutate({ + url: "/api/ExecDiagnosticsPresets", + data: presetData, + title: isUpdate ? "Update Preset" : "Save Preset", + message: isUpdate + ? `Preset "${presetName}" updated successfully` + : `Preset "${presetName}" saved successfully`, + }); + }; + + const handleDeletePreset = () => { + if (!selectedPreset || selectedPreset.isBuiltin) { + return; + } + + deletePresetApi.mutate({ + url: "/api/ExecDiagnosticsPresets", + data: { + GUID: selectedPreset.value, + action: "delete", + }, + title: "Delete Preset", + message: `Preset "${selectedPreset.label}" deleted successfully`, + }); + + formControl.setValue("queryPreset", null); + formControl.setValue("presetName", ""); + setSelectedPreset(null); + }; + + const onSubmit = (values) => { + if (values.query && values.query.trim()) { + onSubmitFilter({ + ...values, + presetDisplayName: values.presetName || selectedPreset?.label || null, + columns: selectedPreset?.columns || null, + }); + setExpanded(false); + } + }; + + const handleClear = () => { + formControl.reset({ query: "", presetName: "", queryPreset: null }); + onSubmitFilter({ query: "", presetDisplayName: null, columns: null }); + // Only clear selectedPreset if it's a built-in preset + // Keep custom preset reference so user can continue editing and saving + if (selectedPreset?.isBuiltin) { + setSelectedPreset(null); + } + setExpanded(true); + }; + + return ( + setExpanded(!expanded)}> + }> + Query + + + + + Requirements + + • Application Insights must be deployed for your CIPP environment +
• The Function App's managed identity must have Reader{" "} + permissions on the Application Insights resource +
• Queries are executed using Kusto Query Language (KQL) +
+
+ + + + + + + + + + + + + + + + + + {savePresetApi.isPending ? : } + + + + + + + {deletePresetApi.isPending ? ( + + ) : ( + + )} + + + + + + + + + + ago(1h)\n| where severityLevel >= 2\n| project timestamp, message, severityLevel\n| order by timestamp desc`} + helperText="Enter a valid Kusto Query Language (KQL) query to execute against Application Insights" + sx={{ + "& textarea": { + fontFamily: "monospace", + fontSize: "0.875rem", + }, + }} + /> + + + + + + + +
+
+
+ ); +}; + +export default CippDiagnosticsFilter; diff --git a/src/components/CippTable/CippGraphExplorerFilter.js b/src/components/CippTable/CippGraphExplorerFilter.js index 3167e5b7ca91..9d296e50ef2f 100644 --- a/src/components/CippTable/CippGraphExplorerFilter.js +++ b/src/components/CippTable/CippGraphExplorerFilter.js @@ -10,8 +10,8 @@ import { } from "@mui/icons-material"; import { useForm, useWatch } from "react-hook-form"; import { debounce } from "lodash"; -import CippButtonCard from "/src/components/CippCards/CippButtonCard"; -import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; +import CippButtonCard from "../CippCards/CippButtonCard"; +import CippFormComponent from "../CippComponents/CippFormComponent"; import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; import { useSettings } from "../../hooks/use-settings"; import { CippApiResults } from "../CippComponents/CippApiResults"; @@ -47,6 +47,7 @@ const CippGraphExplorerFilter = ({ $expand: "", $top: "", $search: "", + $orderby: "", $format: "", NoPagination: false, ReverseTenantLookup: false, @@ -326,6 +327,10 @@ const CippGraphExplorerFilter = ({ Key: "$expand", Value: formParameters.$expand, }, + { + Key: "$orderby", + Value: formParameters.$orderby, + }, { Key: "$format", Value: formParameters.$format, @@ -722,6 +727,17 @@ const CippGraphExplorerFilter = ({ />
+ {/* OrderBy Field */} + + + + {/* Format Field */} 0 - ? "warning.main" - : "primary.main", + ? "warning.main" + : (persistentQueueData || queueData)?.Status === "Failed" + ? "error.main" + : (persistentQueueData || queueData)?.RunningTasks > 0 + ? "warning.main" + : "primary.main", }} > @@ -339,16 +339,16 @@ export const CippQueueTracker = ({ queueId, queryKey, title, onQueueComplete }) ? "rgba(102, 187, 106, 0.15)" : "success.light" : task.Status === "Failed" - ? theme.palette.mode === "dark" - ? "rgba(244, 67, 54, 0.15)" - : "error.light" - : task.Status === "Running" - ? theme.palette.mode === "dark" - ? "rgba(255, 152, 0, 0.15)" - : "warning.light" - : theme.palette.mode === "dark" - ? "rgba(255,255,255,0.05)" - : "grey.100", + ? theme.palette.mode === "dark" + ? "rgba(244, 67, 54, 0.15)" + : "error.light" + : task.Status === "Running" + ? theme.palette.mode === "dark" + ? "rgba(255, 152, 0, 0.15)" + : "warning.light" + : theme.palette.mode === "dark" + ? "rgba(255,255,255,0.05)" + : "grey.100", transition: "all 0.2s ease-in-out", "&:hover": { transform: "translateY(-1px)", @@ -389,10 +389,10 @@ export const CippQueueTracker = ({ queueId, queryKey, title, onQueueComplete }) task.Status === "Completed" ? "success.main" : task.Status === "Failed" - ? "error.main" - : task.Status === "Running" - ? "warning.main" - : "text.secondary", + ? "error.main" + : task.Status === "Running" + ? "warning.main" + : "text.secondary", })} > {task.Status} diff --git a/src/components/CippTestDetail/CippTestDetailOffCanvas.jsx b/src/components/CippTestDetail/CippTestDetailOffCanvas.jsx new file mode 100644 index 000000000000..3a5b29a24301 --- /dev/null +++ b/src/components/CippTestDetail/CippTestDetailOffCanvas.jsx @@ -0,0 +1,296 @@ +import React from "react"; +import { Card, CardContent, Box, Stack, Chip, Typography } from "@mui/material"; +import { KeyboardArrowRight } from "@mui/icons-material"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { Grid } from "@mui/system"; +import standardsData from "../../data/standards.json"; + +const getStatusColor = (status) => { + switch (status?.toLowerCase()) { + case "passed": + return "success"; + case "failed": + return "error"; + case "investigate": + return "warning"; + case "skipped": + return "default"; + default: + return "default"; + } +}; + +const getRiskColor = (risk) => { + switch (risk?.toLowerCase()) { + case "high": + return "error"; + case "medium": + return "warning"; + case "low": + return "info"; + default: + return "default"; + } +}; + +const getImpactColor = (impact) => { + switch (impact?.toLowerCase()) { + case "high": + return "error"; + case "medium": + return "warning"; + case "low": + return "info"; + default: + return "default"; + } +}; + +const checkCIPPStandardAvailable = (testName) => { + if (!testName) return "No"; + console.log(testName); + // Check if any standard's tag array contains a reference to this test + const hasStandard = standardsData.some((standard) => { + if (!standard.tag || !Array.isArray(standard.tag)) return false; + // Check if any tag matches the test name or contains it + return standard.tag.some((tag) => { + const tagLower = tag.toLowerCase(); + const testLower = testName.toLowerCase(); + return tagLower.includes(testLower) || testLower.includes(tagLower); + }); + }); + + return hasStandard ? "Yes" : "No"; +}; + +// Shared markdown styling for consistent rendering +const markdownStyles = { + "& a": { + color: (theme) => theme.palette.primary.main, + textDecoration: "underline", + "&:hover": { + textDecoration: "none", + }, + }, + color: "text.secondary", + fontSize: "0.875rem", + lineHeight: 1.43, + "& p": { + my: 1, + }, + "& ul": { + my: 1, + pl: 2, + }, + "& li": { + my: 0.5, + }, + "& h1, & h2, & h3, & h4, & h5, & h6": { + mt: 2, + mb: 1, + fontWeight: "bold", + }, + "& table": { + width: "100%", + borderCollapse: "collapse", + marginTop: 2, + marginBottom: 2, + }, + "& th, & td": { + border: 1, + borderColor: "divider", + padding: 1, + textAlign: "left", + }, + "& th": { + backgroundColor: "action.hover", + fontWeight: "bold", + }, + "& code": { + backgroundColor: "action.hover", + padding: "2px 6px", + borderRadius: 1, + fontSize: "0.85em", + }, + "& pre": { + backgroundColor: "action.hover", + padding: 2, + borderRadius: 1, + overflow: "auto", + }, +}; + +export const CippTestDetailOffCanvas = ({ row }) => { + return ( + + + + ({ + xs: `1px solid ${theme.palette.divider}`, + md: "none", + }), + borderRight: (theme) => ({ + md: `1px solid ${theme.palette.divider}`, + }), + }} + > + + + + Risk + + + + + + + + ({ + xs: `1px solid ${theme.palette.divider}`, + md: "none", + }), + borderRight: (theme) => ({ + md: `1px solid ${theme.palette.divider}`, + }), + }} + > + + + + User Impact + + + + + + + + ({ + xs: `1px solid ${theme.palette.divider}`, + md: "none", + }), + borderRight: (theme) => ({ + md: `1px solid ${theme.palette.divider}`, + }), + }} + > + + + + Effort + + + + + + + + + + + + Standard Available + + + + + + + + + + + {row.ResultMarkdown && ( + + + + {row.Name} + + + + ( + + {children} + + ), + }} + > + {row.ResultMarkdown} + + + + + )} + + + + + + What did we check + + + {row.Category && ( + + + Category + + {row.Category} + + )} + + {row.Description && ( + + ( + + {children} + + ), + }} + > + {row.Description} + + + )} + + + + + ); +}; diff --git a/src/components/CippWizard/CIPPDeploymentUpdateTokens.jsx b/src/components/CippWizard/CIPPDeploymentUpdateTokens.jsx index 50fef63317f2..6c863f7cac49 100644 --- a/src/components/CippWizard/CIPPDeploymentUpdateTokens.jsx +++ b/src/components/CippWizard/CIPPDeploymentUpdateTokens.jsx @@ -1,10 +1,11 @@ import { useState } from "react"; -import { Stack, Typography, CircularProgress, SvgIcon, Box } from "@mui/material"; -import { CheckCircle } from "@mui/icons-material"; +import { Stack, Typography, CircularProgress, SvgIcon, Box, Chip, Skeleton } from "@mui/material"; +import { CheckCircle, Person, Apartment } from "@mui/icons-material"; import CippButtonCard from "../CippCards/CippButtonCard"; import { ApiGetCall } from "../../api/ApiCall"; import { CippApiResults } from "../CippComponents/CippApiResults"; import { CIPPM365OAuthButton } from "../CippComponents/CIPPM365OAuthButton"; +import { getCippTranslation } from "../../utils/get-cipp-translation"; export const CIPPDeploymentUpdateTokens = ({ formControl }) => { const [tokens, setTokens] = useState(null); @@ -12,7 +13,7 @@ export const CIPPDeploymentUpdateTokens = ({ formControl }) => { // Get application ID information for the card header const appId = ApiGetCall({ url: `/api/ExecListAppId`, - queryKey: `ExecListAppId`, + queryKey: "listAppId", waiting: true, }); @@ -46,10 +47,79 @@ export const CIPPDeploymentUpdateTokens = ({ formControl }) => { /> } > - + Click the button to refresh the Graph token for your tenants using popup authentication. - This method opens a popup window where you can sign in to your Microsoft account. + Use this to update your refresh token or change the logged in user. This method opens a + popup window where you can sign in to your Microsoft account. + + + Current Tenant Info + + + {(appId.isLoading || appId.isFetching) && ( + + + + + + + + )} + {!appId.isLoading && !appId.isFetching && appId?.data?.orgName && ( + + + + + + + + {appId.data.orgName} + + + {appId.data.tenantId} + + + {appId.data.authenticatedUserDisplayName && ( + + + + + + {appId.data.authenticatedUserDisplayName} + + + {appId.data.authenticatedUserPrincipalName} + + + )} + + + {appId.data.isPartnerTenant ? ( + + ) : ( + + )} + + + )} + diff --git a/src/components/CippWizard/CippAlertsStep.jsx b/src/components/CippWizard/CippAlertsStep.jsx index ba4e62c7f9f5..d0d8689b85d2 100644 --- a/src/components/CippWizard/CippAlertsStep.jsx +++ b/src/components/CippWizard/CippAlertsStep.jsx @@ -16,6 +16,8 @@ export const CippAlertsStep = (props) => { { value: "4h", label: "Every 4 hours" }, { value: "1d", label: "Every 1 day" }, { value: "7d", label: "Every 7 days" }, + { value: "14d", label: "Every 14 days" }, + { value: "21d", label: "Every 21 days" }, { value: "30d", label: "Every 30 days" }, { value: "365d", label: "Every 365 days" }, ]; diff --git a/src/components/CippWizard/CippSAMDeploy.jsx b/src/components/CippWizard/CippSAMDeploy.jsx index 2cb619fef7aa..d38d0f66ddf2 100644 --- a/src/components/CippWizard/CippSAMDeploy.jsx +++ b/src/components/CippWizard/CippSAMDeploy.jsx @@ -92,7 +92,10 @@ export const CippSAMDeploy = (props) => { here -
  • (Temporary) Global Administrator permissions for the CIPP Service Account
  • +
  • + An account with at minimum:
  • Application Administrator
  • +
  • User Administrator
  • +
  • Multi-factor authentication enabled for the CIPP Service Account, with no trusted locations or other exclusions. diff --git a/src/components/CippWizard/CippTenantModeDeploy.jsx b/src/components/CippWizard/CippTenantModeDeploy.jsx index 8f8683af405e..52f9722e6254 100644 --- a/src/components/CippWizard/CippTenantModeDeploy.jsx +++ b/src/components/CippWizard/CippTenantModeDeploy.jsx @@ -1,10 +1,22 @@ import { useEffect } from "react"; -import { Stack, Box, Typography, Link } from "@mui/material"; +import { + Stack, + Box, + Typography, + Link, + Chip, + Skeleton, + SvgIcon, + IconButton, + Tooltip, +} from "@mui/material"; +import { Person, Apartment, Sync } from "@mui/icons-material"; import { CIPPM365OAuthButton } from "../CippComponents/CIPPM365OAuthButton"; import { CippApiResults } from "../CippComponents/CippApiResults"; -import { ApiPostCall } from "../../api/ApiCall"; +import { ApiPostCall, ApiGetCall } from "../../api/ApiCall"; import { CippWizardStepButtons } from "./CippWizardStepButtons"; import { CippTenantTable } from "./CippTenantTable"; +import { getCippTranslation } from "../../utils/get-cipp-translation"; export const CippTenantModeDeploy = (props) => { const { formControl, currentStep, onPreviousStep, onNextStep } = props; @@ -13,8 +25,15 @@ export const CippTenantModeDeploy = (props) => { required: true, }); - const updateRefreshToken = ApiPostCall({ urlfromdata: true }); - const addTenant = ApiPostCall({ urlfromdata: true }); + const updateRefreshToken = ApiPostCall({ urlfromdata: true, relatedQueryKeys: ["listAppId"] }); + const addTenant = ApiPostCall({ urlfromdata: true, relatedQueryKeys: ["tenants-table"] }); + + // Get partner tenant info using the same API call as CIPPM365OAuthButton + const partnerTenantInfo = ApiGetCall({ + url: `/api/ExecListAppId`, + queryKey: "listAppId", + waiting: true, + }); useEffect(() => { if (updateRefreshToken.isSuccess) { @@ -30,18 +49,28 @@ export const CippTenantModeDeploy = (props) => { return ( - - {/* Partner Tenant (GDAP) */} - - Partner Tenant - + + + Partner Tenant + + + partnerTenantInfo.refetch()} + disabled={partnerTenantInfo.isLoading} + > + + + + Using GDAP is recommended for CIPP, however you can also authenticate to individual - tenants. It is still highly recommended to connect to your partner tenant first, even if - you are not a Microsoft CSP. This allows CIPP to send notifications, perform permission - checks, and update permissions when required. + tenants. It is required to connect to your partner tenant first, even if you are not a + Microsoft CSP. This is where the multi-tenant App Registration (CIPP-SAM) is installed. It + also allows CIPP to send notifications, perform permission checks, and update permissions + when required. Please remember to log onto a service account dedicated for CIPP. More info? Check out the{" "} @@ -55,26 +84,142 @@ export const CippTenantModeDeploy = (props) => { . - - - { - const updatedTokenData = { - ...tokenData, - tenantMode: "GDAP", - }; - updateRefreshToken.mutate({ - url: "/api/ExecUpdateRefreshToken", - data: updatedTokenData, - }); + {(partnerTenantInfo.isLoading || partnerTenantInfo.isFetching) && ( + + - + > + + + + + + + + + + )} + + {!partnerTenantInfo.isLoading && + !partnerTenantInfo.isFetching && + partnerTenantInfo?.data?.orgName && ( + + + + + + + + + + {partnerTenantInfo.data.orgName} + + + {partnerTenantInfo.data.tenantId} + + + {partnerTenantInfo.data.authenticatedUserDisplayName && ( + + + + + + {partnerTenantInfo.data.authenticatedUserDisplayName} + + + {partnerTenantInfo.data.authenticatedUserPrincipalName} + + + )} + + + {partnerTenantInfo.data.isPartnerTenant ? ( + + ) : ( + + )} + + + + + )} + + {!partnerTenantInfo.isLoading && + !partnerTenantInfo.isFetching && + !partnerTenantInfo?.data?.orgName && ( + + + + + No partner tenant connected. Click the button below to authenticate with your + partner tenant. + + + + + )} + + + { + const updatedTokenData = { + ...tokenData, + tenantMode: "GDAP", + }; + updateRefreshToken.mutate({ + url: "/api/ExecUpdateRefreshToken", + data: updatedTokenData, + }); + }} + buttonText={ + partnerTenantInfo?.data?.orgName + ? "Change Partner Tenant" + : "Connect to Partner Tenant" + } + showSuccessAlert={false} + promptBeforeAuth={ + partnerTenantInfo?.data?.orgName + ? `Are you sure you want to change the partner tenant from '${partnerTenantInfo?.data?.orgName}'? If you are trying to add another tenant, use the per-tenant authentication below.` + : false + } + scope="https://graph.microsoft.com/DelegatedPermissionGrant.ReadWrite.All https://graph.microsoft.com/Directory.ReadWrite.All https://graph.microsoft.com/AppRoleAssignment.ReadWrite.All offline_access profile openid" + /> + + {/* Per-Tenant */} @@ -86,34 +231,65 @@ export const CippTenantModeDeploy = (props) => { wrong tenant? Use the table below to remove it. + {!partnerTenantInfo?.data?.orgName && ( + + + Please connect to your partner tenant first before adding separate tenants. + + + )} + - { - const updatedTokenData = { - ...tokenData, - tenantMode: "perTenant", - }; - addTenant.mutate({ - url: "/api/ExecAddTenant", - data: updatedTokenData, - }); - }} - buttonText="Connect to Separate Tenants" - showSuccessAlert={false} - /> + + {!partnerTenantInfo?.data?.orgName && ( + + )} + + { + if (!partnerTenantInfo?.data?.orgName) return; + const updatedTokenData = { + ...tokenData, + tenantMode: "perTenant", + }; + addTenant.mutate({ + url: "/api/ExecAddTenant", + data: updatedTokenData, + }); + }} + buttonText="Connect to Separate Tenants" + showSuccessAlert={false} + scope="https://graph.microsoft.com/DelegatedPermissionGrant.ReadWrite.All https://graph.microsoft.com/Directory.ReadWrite.All https://graph.microsoft.com/AppRoleAssignment.ReadWrite.All offline_access profile openid" + /> + + - + + + + + { const { postUrl, formControl, onPreviousStep, onNextStep, currentStep } = props; diff --git a/src/components/CippWizard/CippWizardOffboarding.jsx b/src/components/CippWizard/CippWizardOffboarding.jsx index 80b1dd8855e5..44af44afbb27 100644 --- a/src/components/CippWizard/CippWizardOffboarding.jsx +++ b/src/components/CippWizard/CippWizardOffboarding.jsx @@ -383,6 +383,17 @@ export const CippWizardOffboarding = (props) => { formControl={formControl} /> + + + + diff --git a/src/components/CippWizard/CippWizardPage.jsx b/src/components/CippWizard/CippWizardPage.jsx index 66025bcdd379..6266a3ec1c4b 100644 --- a/src/components/CippWizard/CippWizardPage.jsx +++ b/src/components/CippWizard/CippWizardPage.jsx @@ -23,23 +23,10 @@ const CippWizardPage = (props) => { sx={{ backgroundColor: "background.default", flexGrow: 1, - py: 4, + pb: 4, }} > - {backButton && ( - - )} diff --git a/src/components/ExecutiveReportButton.js b/src/components/ExecutiveReportButton.js index e7d0cdde65de..922a3c550850 100644 --- a/src/components/ExecutiveReportButton.js +++ b/src/components/ExecutiveReportButton.js @@ -2533,7 +2533,7 @@ const ExecutiveReportDocument = ({ }; export const ExecutiveReportButton = (props) => { - const { tenantName, tenantId, userStats, standardsData, organizationData, ...other } = props; + const { ...other } = props; const settings = useSettings(); const brandingSettings = settings.customBranding; @@ -2550,6 +2550,22 @@ export const ExecutiveReportButton = (props) => { infographics: true, }); + // Fetch organization data - only when preview is open + const organization = ApiGetCall({ + url: "/api/ListOrg", + queryKey: `${settings.currentTenant}-ListOrg-report`, + data: { tenantFilter: settings.currentTenant }, + waiting: previewOpen, + }); + + // Fetch user counts - only when preview is open + const dashboard = ApiGetCall({ + url: "/api/ListuserCounts", + data: { tenantFilter: settings.currentTenant }, + queryKey: `${settings.currentTenant}-ListuserCounts-report`, + waiting: previewOpen, + }); + // Only fetch additional data when preview dialog is opened const secureScore = useSecureScore({ waiting: previewOpen }); @@ -2606,7 +2622,9 @@ export const ExecutiveReportButton = (props) => { // Check if all data is loaded (either successful or failed) - only relevant when preview is open const isDataLoading = previewOpen && - (secureScore.isFetching || + (organization.isFetching || + dashboard.isFetching || + secureScore.isFetching || licenseData.isFetching || deviceData.isFetching || conditionalAccessData.isFetching || @@ -2615,7 +2633,9 @@ export const ExecutiveReportButton = (props) => { const hasAllDataFinished = !previewOpen || - ((secureScore.isSuccess || secureScore.isError) && + ((organization.isSuccess || organization.isError) && + (dashboard.isSuccess || dashboard.isError) && + (secureScore.isSuccess || secureScore.isError) && (licenseData.isSuccess || licenseData.isError) && (deviceData.isSuccess || deviceData.isError) && (conditionalAccessData.isSuccess || conditionalAccessData.isError) && @@ -2625,6 +2645,18 @@ export const ExecutiveReportButton = (props) => { // Button is always available now since we don't need to wait for data const shouldShowButton = true; + const tenantName = organization.data?.displayName || "Tenant"; + const tenantId = organization.data?.id; + const userStats = { + licensedUsers: dashboard.data?.LicUsers || 0, + unlicensedUsers: + dashboard.data?.Users && dashboard.data?.LicUsers + ? dashboard.data?.Users - dashboard.data?.LicUsers + : 0, + guests: dashboard.data?.Guests || 0, + globalAdmins: dashboard.data?.Gas || 0, + }; + const fileName = `Executive_Report_${tenantName?.replace(/[^a-zA-Z0-9]/g, "_") || "Tenant"}_${ new Date().toISOString().split("T")[0] }.pdf`; @@ -2655,8 +2687,8 @@ export const ExecutiveReportButton = (props) => { tenantName={tenantName} tenantId={tenantId} userStats={userStats} - standardsData={standardsData} - organizationData={organizationData} + standardsData={driftComplianceData.data} + organizationData={organization.data} brandingSettings={brandingSettings} secureScoreData={secureScore.isSuccess ? secureScore : null} licensingData={licenseData.isSuccess ? licenseData?.data : null} @@ -2687,8 +2719,8 @@ export const ExecutiveReportButton = (props) => { tenantName, tenantId, userStats, - standardsData, - organizationData, + organization.data, + dashboard.data, brandingSettings, secureScore?.isSuccess, licenseData?.isSuccess, @@ -3007,8 +3039,8 @@ export const ExecutiveReportButton = (props) => { tenantName={tenantName} tenantId={tenantId} userStats={userStats} - standardsData={standardsData} - organizationData={organizationData} + standardsData={driftComplianceData.data} + organizationData={organization.data} brandingSettings={brandingSettings} secureScoreData={secureScore.isSuccess ? secureScore : null} licensingData={licenseData.isSuccess ? licenseData?.data : null} diff --git a/src/components/actions-menu.js b/src/components/actions-menu.js index b63ed33e74c8..ff11c55d2edd 100644 --- a/src/components/actions-menu.js +++ b/src/components/actions-menu.js @@ -92,6 +92,7 @@ export const ActionsMenu = (props) => { api={actionData.action} row={actionData.data} relatedQueryKeys={queryKeys} + {...actionData.action} /> )} diff --git a/src/components/pdfExportButton.js b/src/components/pdfExportButton.js index 0755b6037079..f0c0ce803b08 100644 --- a/src/components/pdfExportButton.js +++ b/src/components/pdfExportButton.js @@ -5,6 +5,22 @@ import autoTable from "jspdf-autotable"; import { getCippFormatting } from "../utils/get-cipp-formatting"; import { useSettings } from "../hooks/use-settings"; +// Flatten nested objects so deeply nested properties export properly. +// This function only restructures data without formatting - formatting happens later in one pass. +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])) { + Object.assign(flattened, flattenObject(obj[key], fullKey)); + } else { + // Store the raw value - formatting will happen in a single pass later + flattened[fullKey] = obj[key]; + } + }); + return flattened; +}; + // Shared helper so the toolbar buttons and bulk export path share the same PDF logic. export const exportRowsToPdf = ({ rows = [], @@ -21,7 +37,7 @@ export const exportRowsToPdf = ({ const size = "A3"; const orientation = "landscape"; const doc = new jsPDF(orientation, unit, size); - const tableData = rows.map((row) => row.original ?? row); + const tableData = rows.map((row) => flattenObject(row.original ?? row)); const exportColumns = columns .filter((c) => columnVisibility[c.id]) @@ -30,8 +46,11 @@ export const exportRowsToPdf = ({ // Use the existing formatting helper so PDF output mirrors table formatting. const formattedData = tableData.map((row) => { const formattedRow = {}; - Object.keys(row).forEach((key) => { - formattedRow[key] = getCippFormatting(row[key], key, "text", false); + exportColumns.forEach((col) => { + const key = col.dataKey; + if (key in row) { + formattedRow[key] = getCippFormatting(row[key], key, "text", false); + } }); return formattedRow; }); @@ -58,7 +77,7 @@ export const exportRowsToPdf = ({ const columnWidths = exportColumns.map((col) => { const headerLength = col.header.length; const maxContentLength = Math.max( - ...formattedData.map((row) => String(row[col.dataKey] || "").length) + ...formattedData.map((row) => String(row[col.dataKey] || "").length), ); const estimatedWidth = Math.max(headerLength, maxContentLength) * 6; return Math.min(estimatedWidth, (availableWidth / columnCount) * 1.5); @@ -66,7 +85,7 @@ export const exportRowsToPdf = ({ const totalEstimatedWidth = columnWidths.reduce((sum, width) => sum + width, 0); const normalizedWidths = columnWidths.map( - (width) => (width / totalEstimatedWidth) * availableWidth + (width) => (width / totalEstimatedWidth) * availableWidth, ); // Honor tenant branding colors when present so exports stay on-brand. diff --git a/src/contexts/settings-context.js b/src/contexts/settings-context.js index 35c87c90d658..37bb7a1cfdd6 100644 --- a/src/contexts/settings-context.js +++ b/src/contexts/settings-context.js @@ -80,6 +80,7 @@ const initialSettings = { }, persistFilters: false, lastUsedFilters: {}, + breadcrumbMode: "hierarchical", }; const initialState = { @@ -112,6 +113,12 @@ export const SettingsProvider = (props) => { ...restored, isInitialized: true, })); + } else { + // No stored settings found, initialize with defaults + setState((prevState) => ({ + ...prevState, + isInitialized: true, + })); } }, []); @@ -125,14 +132,22 @@ export const SettingsProvider = (props) => { const handleUpdate = useCallback((settings) => { setState((prevState) => { + // Filter out null and undefined values to prevent resetting settings + const filteredSettings = Object.entries(settings).reduce((acc, [key, value]) => { + if (value !== null && value !== undefined) { + acc[key] = value; + } + return acc; + }, {}); + storeSettings({ ...prevState, - ...settings, + ...filteredSettings, }); return { ...prevState, - ...settings, + ...filteredSettings, }; }); }, []); diff --git a/src/data/DiagnosticsPresets.json b/src/data/DiagnosticsPresets.json new file mode 100644 index 000000000000..ac354677b226 --- /dev/null +++ b/src/data/DiagnosticsPresets.json @@ -0,0 +1,56 @@ +[ + { + "name": "Completed Tasks Summary (Last 24h)", + "id": "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d", + "query": "customEvents\n| where timestamp between (ago(1d) .. now())\n| where name == \"CIPP.TaskCompleted\"\n| extend TaskName = tostring(customDimensions.TaskName)\n , Command = tostring(customDimensions.Command)\n , Tenant = tostring(customDimensions.Tenant)\n , DurationMs = todouble(customMeasurements.DurationMs)\n| summarize\n Count = count(),\n TotalDurationMs = sum(DurationMs),\n AvgDurationMs = avg(DurationMs),\n MaxDurationMs = max(DurationMs)\n by TaskName, Command, Tenant\n| extend name = \"CIPP.TaskCompleted\"\n| order by TotalDurationMs desc", + "isBuiltin": true, + "columns": [ + "TaskName", + "Command", + "Tenant", + "Count", + "TotalDurationMs", + "AvgDurationMs", + "MaxDurationMs" + ] + }, + { + "name": "Completed Standards Summary (Last 24h)", + "id": "b2c3d4e5-f6a7-4b5c-9d0e-1f2a3b4c5d6e", + "query": "customEvents\n| where timestamp between (ago(1d) .. now())\n| where name == \"CIPP.StandardCompleted\"\n| extend TaskName = tostring(customDimensions.TaskName)\n , Command = tostring(customDimensions.Command)\n , Tenant = tostring(customDimensions.Tenant)\n , DurationMs = todouble(customMeasurements.DurationMs)\n| summarize\n Count = count(),\n TotalDurationMs = sum(DurationMs),\n AvgDurationMs = avg(DurationMs),\n MaxDurationMs = max(DurationMs)\n by TaskName, Command, Tenant\n| extend name = \"CIPP.StandardCompleted\"\n| order by TotalDurationMs desc", + "isBuiltin": true, + "columns": [ + "TaskName", + "Command", + "Tenant", + "Count", + "TotalDurationMs", + "AvgDurationMs", + "MaxDurationMs" + ] + }, + { + "name": "Console Logs (Last 24h)", + "id": "c3d4e5f6-a7b8-4c5d-0e1f-2a3b4c5d6e7f", + "query": "customEvents\n| where timestamp > ago(1d)\n| where name == \"CIPP.ConsoleLog\"\n| extend Message = tostring(customDimensions['Message'])\n , Level = tostring(customDimensions['Level'])\n , InvocationId = tostring(customDimensions['InvocationId'])\n| project timestamp, name, Level, Message, InvocationId\n| order by timestamp desc", + "isBuiltin": true, + "columns": [ + "timestamp", + "Level", + "Message", + "InvocationId" + ] + }, + { + "name": "Console Errors and Warnings (Last 24h)", + "id": "d4e5f6a7-b8c9-4d5e-1f2a-3b4c5d6e7f8a", + "query": "customEvents\n| where timestamp > ago(1d)\n| where name == \"CIPP.ConsoleLog\"\n| where tostring(customDimensions['Level']) in ('Error', 'Warning')\n| extend Message = tostring(customDimensions['Message'])\n , Level = tostring(customDimensions['Level'])\n , InvocationId = tostring(customDimensions['InvocationId'])\n| project timestamp, name, Level, Message, InvocationId\n| order by timestamp desc", + "isBuiltin": true, + "columns": [ + "timestamp", + "Level", + "Message", + "InvocationId" + ] + } +] \ No newline at end of file diff --git a/src/data/Extensions.json b/src/data/Extensions.json index 3009704f99cd..21471f5e295c 100644 --- a/src/data/Extensions.json +++ b/src/data/Extensions.json @@ -274,7 +274,7 @@ "links": [ { "name": "HaloPSA Documentation", - "url": "https://halopsa.com/guides/" + "url": "https://usehalo.com/halopsa/guides/2697" } ], "SettingOptions": [ @@ -866,6 +866,16 @@ "compareValue": true, "action": "disable" } + }, + { + "type": "switch", + "name": "CFZTNA.WebhookEnabled", + "label": "Use CloudFlare Service Account credentials with webhooks.", + "condition": { + "field": "CFZTNA.Enabled", + "compareType": "is", + "compareValue": true + } } ], "mappingRequired": false diff --git a/src/data/alerts.json b/src/data/alerts.json index d06a880a09bf..ec398d5e490e 100644 --- a/src/data/alerts.json +++ b/src/data/alerts.json @@ -15,7 +15,7 @@ "recommendedRunInterval": "1d" }, { - "name": "AlertSmtpAuthSuccess", + "name": "SmtpAuthSuccess", "label": "Alert on SMTP AUTH usage with success, helps to phase out SMTP AUTH (Entra P1 Required)", "recommendedRunInterval": "1d" }, @@ -31,12 +31,22 @@ }, { "name": "InactiveLicensedUsers", - "label": "Alert on licensed users that have not logged in for 90 days", + "label": "Alert on licensed users that have not logged in for X days", + "recommendedRunInterval": "1d", "requiresInput": true, - "inputType": "switch", - "inputLabel": "Exclude disabled users?", - "inputName": "InactiveLicensedUsersExcludeDisabled", - "recommendedRunInterval": "1d" + "multipleInput": true, + "inputs": [ + { + "inputType": "number", + "inputLabel": "Days since last login (default: 90)", + "inputName": "DaysSinceLastLogin" + }, + { + "inputType": "switch", + "inputLabel": "Exclude disabled users?", + "inputName": "ExcludeDisabled" + } + ] }, { "name": "EntraConnectSyncStatus", @@ -76,8 +86,22 @@ }, { "name": "ExpiringLicenses", - "label": "Alert on licenses expiring in 30 days", - "recommendedRunInterval": "7d" + "label": "Alert on licenses expiring in X days", + "recommendedRunInterval": "7d", + "requiresInput": true, + "multipleInput": true, + "inputs": [ + { + "inputType": "number", + "inputLabel": "Days until expiration (default: 30)", + "inputName": "ExpiringLicensesDays" + }, + { + "inputType": "switch", + "inputLabel": "Alert only on unassigned licenses", + "inputName": "ExpiringLicensesUnassignedOnly" + } + ] }, { "name": "NewAppApproval", @@ -89,6 +113,11 @@ "label": "Alert on Security Defaults automatic enablement", "recommendedRunInterval": "1d" }, + { + "name": "SecDefaultsDisabled", + "label": "Alert when Security Defaults is disabled with no Conditional Access policies", + "recommendedRunInterval": "1d" + }, { "name": "DefenderStatus", "label": "Alert if Defender is not running (Tenant must be on-boarded in Lighthouse)", @@ -102,17 +131,117 @@ { "name": "DefenderIncidents", "label": "Alert on new Defender Incidents found", - "recommendedRunInterval": "4h" + "recommendedRunInterval": "4h", + "requiresInput": true, + "multipleInput": true, + "inputs": [ + { + "inputType": "autoComplete", + "inputLabel": "Incident severities to include", + "inputName": "IncidentSeverities", + "creatable": false, + "multiple": true, + "options": [ + { "label": "All Severities", "value": "All" }, + { "label": "High", "value": "high" }, + { "label": "Medium", "value": "medium" }, + { "label": "Low", "value": "low" }, + { "label": "Informational", "value": "informational" } + ] + } + ] + }, + { + "name": "DefenderAlerts", + "label": "Alert on new Defender Alerts found", + "recommendedRunInterval": "4h", + "requiresInput": true, + "multipleInput": true, + "inputs": [ + { + "inputType": "autoComplete", + "inputLabel": "Alert severities to include", + "inputName": "AlertSeverities", + "creatable": false, + "multiple": true, + "options": [ + { "label": "All Severities", "value": "All" }, + { "label": "High", "value": "high" }, + { "label": "Medium", "value": "medium" }, + { "label": "Low", "value": "low" }, + { "label": "Informational", "value": "informational" } + ] + } + ] }, { "name": "Vulnerabilities", - "label": "Alert on vulnerabilities older than X hours", + "label": "Alert on software vulnerabilities", "requiresInput": true, - "inputType": "number", - "inputLabel": "Alert on vulnerabilities first seen more than X hours ago (default: 24)", - "inputName": "AgeInHours", + "multipleInput": true, + "inputs": [ + { + "inputType": "number", + "inputLabel": "Minimum age in hours (default: 0)", + "inputName": "VulnerabilityAgeHours" + }, + { + "inputType": "autoComplete", + "inputLabel": "Minimum CVSS severity", + "inputName": "CVSSSeverity", + "creatable": false, + "multiple": false, + "options": [ + { + "label": "Low (0.0+)", + "value": "low" + }, + { + "label": "Medium (4.0+)", + "value": "medium" + }, + { + "label": "High (7.0+)", + "value": "high" + }, + { + "label": "Critical (9.0+)", + "value": "critical" + } + ] + }, + { + "inputType": "autoComplete", + "inputLabel": "Exploitability levels to include", + "inputName": "ExploitabilityLevels", + "creatable": false, + "multiple": true, + "options": [ + { + "label": "All Levels", + "value": "All" + }, + { + "label": "No Known Exploit", + "value": "NoExploit" + }, + { + "label": "Public Exploit Available", + "value": "ExploitIsPublic" + }, + { + "label": "Exploit Verified", + "value": "ExploitIsVerified" + }, + { + "label": "Exploit In Kit", + "value": "ExploitIsInKit" + } + ] + } + ], "recommendedRunInterval": "4h", - "description": "Monitors for software vulnerabilities that were first discovered more than the specified number of hours ago. This helps identify lingering vulnerabilities that may have been missed or not yet remediated. Requires Defender for Endpoint/Business." + "description": "Monitors for software vulnerabilities based on age, CVSS severity, and exploitability level. Filter by minimum CVSS score (low=0+, medium=4+, high=7+, critical=9+) and select which exploitability levels to include. Requires Defender for Endpoint/Business." }, { "name": "UnusedLicenses", @@ -173,6 +302,41 @@ "label": "Alert on device compliance issues", "recommendedRunInterval": "4h" }, + { + "name": "IntunePolicyConflicts", + "label": "Alert on Intune policy or app conflicts/errors", + "recommendedRunInterval": "4h", + "requiresInput": true, + "multipleInput": true, + "inputs": [ + { + "inputType": "switch", + "inputLabel": "Alert per issue (off = aggregated)", + "inputName": "AlertEachIssue" + }, + { + "inputType": "switch", + "inputLabel": "Include policy status issues", + "inputName": "IncludePolicies" + }, + { + "inputType": "switch", + "inputLabel": "Include app install issues", + "inputName": "IncludeApplications" + }, + { + "inputType": "switch", + "inputLabel": "Alert on conflicts", + "inputName": "AlertConflicts" + }, + { + "inputType": "switch", + "inputLabel": "Alert on errors/failures", + "inputName": "AlertErrors" + } + ], + "description": "Monitors Intune policy assignment states and app install statuses for conflicts or errors. Defaults to aggregated alerts with all mechanisms enabled and both conflicts and errors included." + }, { "name": "BreachAlert", "label": "Alert on (new) potentially breached passwords. Generates an alert if a password is found to be breached.", @@ -225,6 +389,26 @@ "recommendedRunInterval": "7d", "description": "Monitors Global Admin accounts and alerts when they don't have an alternate email address set, which is important for password recovery of key accounts." }, + { + "name": "GlobalAdminAllowList", + "label": "Alert on Global Admins outside approved list", + "recommendedRunInterval": "4h", + "requiresInput": true, + "multipleInput": true, + "inputs": [ + { + "inputType": "textField", + "inputLabel": "Approved Global Admin UPN prefixes (comma separated)", + "inputName": "ApprovedGlobalAdmins" + }, + { + "inputType": "switch", + "inputLabel": "Alert per non-compliant admin? (off = single aggregated alert)", + "inputName": "AlertEachAdmin" + } + ], + "description": "Alerts when Global Administrator accounts are present whose UPN prefix (before @domain) is not in your approved comma-separated allow list. Toggle per-admin alerts to get one alert per user or a single aggregated alert." + }, { "name": "NewRiskyUsers", "label": "Alert on new risky users (P2 License Required)", @@ -251,5 +435,45 @@ "name": "ReportOnlyCA", "label": "Alert on tenants with Conditional Access policies in report-only mode", "recommendedRunInterval": "1d" + }, + { + "name": "QuarantineReleaseRequests", + "label": "Alert on quarantine release requests", + "recommendedRunInterval": "30m", + "description": "Monitors for user requests to release quarantined messages and provides a CIPP-native alternative to the external email forwarding method. This helps MSPs maintain secure configurations while getting timely notifications about quarantine activity. Links to the tenant's quarantine page are provided in alerts." + }, + { + "name": "SecureScore", + "label": "Alert on a low Secure Score", + "recommendedRunInterval": "1d", + "requiresInput": true, + "multipleInput": true, + "inputs": [ + { + "inputType": "autoComplete", + "inputLabel": "Threshold type absolute number or percent", + "inputName": "ThresholdType", + "creatable": false, + "multiple": false, + "options": [ + { + "label": "Percent", + "value": "percent" + }, + { + "label": "Absolute", + "value": "absolute" + } + ], + "required": true + }, + { + "inputType": "number", + "inputLabel": "Threshold Value (below this will trigger the alert)", + "inputName": "InputValue", + "required": true + } + ], + "description": "Monitors Secure Score and alerts when it falls below the specified threshold (absolute or percent value). Helps identify security gaps and areas for improvement." } ] diff --git a/src/data/dashboardv2-demo-data.js b/src/data/dashboardv2-demo-data.js new file mode 100644 index 000000000000..e5e23eee579a --- /dev/null +++ b/src/data/dashboardv2-demo-data.js @@ -0,0 +1,131 @@ +// Demo data structure matching Zero Trust Assessment +export const dashboardDemoData = { + ExecutedAt: "2025-12-16T10:00:00Z", + TenantName: "Demo Tenant", + Domain: "demo.contoso.com", + TestResultSummary: { + IdentityPassed: 85, + IdentityTotal: 100, + DevicesPassed: 25, + DevicesTotal: 36, + DataPassed: 20, + DataTotal: 30, + }, + TenantInfo: { + TenantOverview: { + UserCount: 1250, + GuestCount: 85, + GroupCount: 340, + ApplicationCount: 156, + DeviceCount: 765, + ManagedDeviceCount: 733, + }, + OverviewCaMfaAllUsers: { + description: + "Over the past 30 days, 68.5% of sign-ins were protected by conditional access policies enforcing multifactor authentication.", + nodes: [ + { source: "User sign in", target: "No CA applied", value: 394 }, + { source: "User sign in", target: "CA applied", value: 856 }, + { source: "CA applied", target: "No MFA", value: 146 }, + { source: "CA applied", target: "MFA", value: 710 }, + ], + }, + OverviewCaDevicesAllUsers: { + description: "Over the past 30 days, 71.2% of sign-ins were from compliant devices.", + nodes: [ + { source: "User sign in", target: "Unmanaged", value: 500 }, + { source: "User sign in", target: "Managed", value: 1150 }, + { source: "Managed", target: "Non-compliant", value: 260 }, + { source: "Managed", target: "Compliant", value: 890 }, + ], + }, + OverviewAuthMethodsPrivilegedUsers: { + description: "Authentication methods used by privileged users over the past 30 days.", + nodes: [ + { source: "Users", target: "Single factor", value: 5 }, + { source: "Users", target: "Phishable", value: 28 }, + { source: "Users", target: "Phish resistant", value: 15 }, + { source: "Phishable", target: "Phone", value: 8 }, + { source: "Phishable", target: "Authenticator", value: 20 }, + { source: "Phish resistant", target: "Passkey", value: 12 }, + { source: "Phish resistant", target: "WHfB", value: 3 }, + ], + }, + OverviewAuthMethodsAllUsers: { + description: "Authentication methods used by all users over the past 30 days.", + nodes: [ + { source: "Users", target: "Single factor", value: 120 }, + { source: "Users", target: "Phishable", value: 580 }, + { source: "Users", target: "Phish resistant", value: 550 }, + { source: "Phishable", target: "Phone", value: 180 }, + { source: "Phishable", target: "Authenticator", value: 400 }, + { source: "Phish resistant", target: "Passkey", value: 450 }, + { source: "Phish resistant", target: "WHfB", value: 100 }, + ], + }, + DeviceOverview: { + DesktopDevicesSummary: { + description: "Desktop devices (Windows and macOS) by join type and compliance status.", + nodes: [ + // Level 1: Desktop devices to OS + { source: "Desktop devices", target: "Windows", value: 585 }, + { source: "Desktop devices", target: "macOS", value: 75 }, + // Level 2: Windows to join types + { source: "Windows", target: "Entra joined", value: 285 }, + { source: "Windows", target: "Entra registered", value: 100 }, + { source: "Windows", target: "Entra hybrid joined", value: 200 }, + // Level 3: Windows join types to compliance + { source: "Entra joined", target: "Compliant", value: 171 }, + { source: "Entra joined", target: "Non-compliant", value: 42 }, + { source: "Entra joined", target: "Unmanaged", value: 72 }, + { source: "Entra hybrid joined", target: "Compliant", value: 50 }, + { source: "Entra hybrid joined", target: "Non-compliant", value: 23 }, + { source: "Entra hybrid joined", target: "Unmanaged", value: 127 }, + { source: "Entra registered", target: "Compliant", value: 60 }, + { source: "Entra registered", target: "Non-compliant", value: 40 }, + { source: "Entra registered", target: "Unmanaged", value: 0 }, + // Level 2: macOS directly to compliance + { source: "macOS", target: "Compliant", value: 56 }, + { source: "macOS", target: "Non-compliant", value: 15 }, + { source: "macOS", target: "Unmanaged", value: 4 }, + ], + }, + MobileSummary: { + description: "Mobile devices by compliance status.", + nodes: [ + { source: "Mobile devices", target: "Android", value: 105 }, + { source: "Mobile devices", target: "iOS", value: 75 }, + { source: "Android", target: "Android (Company)", value: 72 }, + { source: "Android", target: "Android (Personal)", value: 33 }, + { source: "iOS", target: "iOS (Company)", value: 58 }, + { source: "iOS", target: "iOS (Personal)", value: 17 }, + { source: "Android (Company)", target: "Compliant", value: 60 }, + { source: "Android (Company)", target: "Non-compliant", value: 12 }, + { source: "Android (Personal)", target: "Compliant", value: 10 }, + { source: "Android (Personal)", target: "Non-compliant", value: 23 }, + { source: "iOS (Company)", target: "Compliant", value: 52 }, + { source: "iOS (Company)", target: "Non-compliant", value: 6 }, + { source: "iOS (Personal)", target: "Compliant", value: 11 }, + { source: "iOS (Personal)", target: "Non-compliant", value: 6 }, + ], + }, + ManagedDevices: { + deviceOperatingSystemSummary: { + androidCount: 105, + iosCount: 75, + macOSCount: 75, + windowsCount: 585, + linuxCount: 15, + }, + }, + DeviceCompliance: { + compliantDeviceCount: 400, + nonCompliantDeviceCount: 150, + }, + DeviceOwnership: { + corporateCount: 600, + personalCount: 100, + }, + }, + }, +}; diff --git a/src/data/standards.json b/src/data/standards.json index 886e82fc0caf..9864918bdf39 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -77,9 +77,7 @@ "impactColour": "info", "addedDate": "2024-03-19", "powershellEquivalent": "New-MailContact", - "recommendedBy": [ - "CIPP" - ] + "recommendedBy": ["CIPP"] }, { "name": "standards.DeployContactTemplates", @@ -113,9 +111,7 @@ "impactColour": "info", "addedDate": "2025-05-31", "powershellEquivalent": "New-MailContact", - "recommendedBy": [ - "CIPP" - ] + "recommendedBy": ["CIPP"] }, { "name": "standards.AuditLog", @@ -123,7 +119,9 @@ "tag": [ "CIS M365 5.0 (3.1.1)", "mip_search_auditlog", - "NIST CSF 2.0 (DE.CM-09)" + "NIST CSF 2.0 (DE.CM-09)", + "CISAMSEXO171", + "CISAMSEXO173" ], "helpText": "Enables the Unified Audit Log for tracking and auditing activities. Also runs Enable-OrganizationCustomization if necessary.", "executiveText": "Activates comprehensive activity logging across Microsoft 365 services to track user actions, system changes, and security events. This provides essential audit trails for compliance requirements, security investigations, and regulatory reporting.", @@ -133,17 +131,12 @@ "impactColour": "info", "addedDate": "2021-11-16", "powershellEquivalent": "Enable-OrganizationCustomization", - "recommendedBy": [ - "CIS", - "CIPP" - ] + "recommendedBy": ["CIS", "CIPP"] }, { "name": "standards.RestrictThirdPartyStorageServices", "cat": "Global Standards", - "tag": [ - "CIS M365 5.0 (1.3.7)" - ], + "tag": ["CIS M365 5.0 (1.3.7)"], "helpText": "Restricts third-party storage services in Microsoft 365 on the web by managing the Microsoft 365 on the web service principal. This disables integrations with services like Dropbox, Google Drive, Box, and other third-party storage providers.", "docsDescription": "Third-party storage can be enabled for users in Microsoft 365, allowing them to store and share documents using services such as Dropbox, alongside OneDrive and team sites. This standard ensures Microsoft 365 on the web third-party storage services are restricted by creating and disabling the Microsoft 365 on the web service principal (appId: c1f33bc0-bdb4-4248-ba9b-096807ddb43e). By using external storage services an organization may increase the risk of data breaches and unauthorized access to confidential information. Additionally, third-party services may not adhere to the same security standards as the organization, making it difficult to maintain data privacy and security. Impact is highly dependent upon current practices - if users do not use other storage providers, then minimal impact is likely. However, if users regularly utilize providers outside of the tenant this will affect their ability to continue to do so.", "executiveText": "Prevents employees from using external cloud storage services like Dropbox, Google Drive, and Box within Microsoft 365, reducing data security risks and ensuring all company data remains within controlled corporate systems. This helps maintain data governance and prevents potential data leaks to unauthorized platforms.", @@ -153,9 +146,7 @@ "impactColour": "warning", "addedDate": "2025-06-06", "powershellEquivalent": "New-MgServicePrincipal and Update-MgServicePrincipal", - "recommendedBy": [ - "CIS" - ] + "recommendedBy": ["CIS"] }, { "name": "standards.ProfilePhotos", @@ -207,9 +198,7 @@ "remediate": false }, "powershellEquivalent": "Portal only", - "recommendedBy": [ - "CIPP" - ] + "recommendedBy": ["CIPP"] }, { "name": "standards.Branding", @@ -272,12 +261,9 @@ { "name": "standards.EnableCustomerLockbox", "cat": "Global Standards", - "tag": [ - "CIS M365 5.0 (1.3.6)", - "CustomerLockBoxEnabled" - ], - "helpText": "Enables Customer Lockbox that offers an approval process for Microsoft support to access organization data", - "docsDescription": "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.", + "tag": ["CIS M365 5.0 (1.3.6)", "CustomerLockBoxEnabled"], + "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.", "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", @@ -285,9 +271,7 @@ "impactColour": "info", "addedDate": "2024-01-08", "powershellEquivalent": "Set-OrganizationConfig -CustomerLockBoxEnabled $true", - "recommendedBy": [ - "CIS" - ] + "recommendedBy": ["CIS"] }, { "name": "standards.EnablePronouns", @@ -315,9 +299,7 @@ "impact": "Low Impact", "impactColour": "info", "addedDate": "2025-06-06", - "recommendedBy": [ - "CIPP" - ] + "recommendedBy": ["CIPP"] }, { "name": "standards.AnonReportDisable", @@ -332,9 +314,7 @@ "impactColour": "info", "addedDate": "2021-11-16", "powershellEquivalent": "Update-MgBetaAdminReportSetting -BodyParameter @{displayConcealedNames = $true}", - "recommendedBy": [ - "CIPP" - ] + "recommendedBy": ["CIPP"] }, { "name": "standards.DisableGuestDirectory", @@ -345,7 +325,10 @@ "EIDSCA.AP14", "EIDSCA.ST08", "EIDSCA.ST09", - "NIST CSF 2.0 (PR.AA-05)" + "NIST CSF 2.0 (PR.AA-05)", + "EIDSCAAP07", + "EIDSCAST08", + "EIDSCAST09" ], "helpText": "Disables Guest access to enumerate directory objects. This prevents guest users from seeing other users or guests in the directory.", "docsDescription": "Sets it so guests can view only their own user profile. Permission to view other users isn't allowed. Also restricts guest users from seeing the membership of groups they're in. See exactly what get locked down in the [Microsoft documentation.](https://learn.microsoft.com/en-us/entra/fundamentals/users-default-permissions)", @@ -356,17 +339,12 @@ "impactColour": "info", "addedDate": "2022-05-04", "powershellEquivalent": "Set-AzureADMSAuthorizationPolicy -GuestUserRoleId '2af84b1e-32c8-42b7-82bc-daa82404023b'", - "recommendedBy": [ - "CIPP" - ] + "recommendedBy": ["CIPP"] }, { "name": "standards.DisableBasicAuthSMTP", "cat": "Global Standards", - "tag": [ - "CIS M365 5.0 (6.5.4)", - "NIST CSF 2.0 (PR.IR-01)" - ], + "tag": ["CIS M365 5.0 (6.5.4)", "NIST CSF 2.0 (PR.IR-01)", "ZTNA21799", "CISAMSEXO51"], "helpText": "Disables SMTP AUTH organization-wide, impacting POP and IMAP clients that rely on SMTP for sending emails. Default for new tenants. For more information, see the [Microsoft documentation](https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/authenticated-client-smtp-submission)", "docsDescription": "Disables tenant-wide SMTP basic authentication, including for all explicitly enabled users, impacting POP and IMAP clients that rely on SMTP for sending emails. For more information, see the [Microsoft documentation](https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/authenticated-client-smtp-submission).", "executiveText": "Disables outdated email authentication methods that are vulnerable to security attacks, forcing applications and devices to use modern, more secure authentication protocols. This reduces the risk of email-based security breaches and credential theft.", @@ -376,10 +354,7 @@ "impactColour": "warning", "addedDate": "2021-11-16", "powershellEquivalent": "Set-TransportConfig -SmtpClientAuthenticationDisabled $true", - "recommendedBy": [ - "CIS", - "CIPP" - ] + "recommendedBy": ["CIS", "CIPP"] }, { "name": "standards.ActivityBasedTimeout", @@ -387,7 +362,10 @@ "tag": [ "CIS M365 5.0 (1.3.2)", "spo_idle_session_timeout", - "NIST CSF 2.0 (PR.AA-03)" + "NIST CSF 2.0 (PR.AA-03)", + "ZTNA21813", + "ZTNA21814", + "ZTNA21815" ], "helpText": "Enables and sets Idle session timeout for Microsoft 365 to 1 hour. This policy affects most M365 web apps", "executiveText": "Automatically logs out inactive users from Microsoft 365 applications after a specified time period to prevent unauthorized access to company data on unattended devices. This security measure protects against data breaches when employees leave workstations unlocked.", @@ -427,18 +405,12 @@ "impactColour": "warning", "addedDate": "2022-04-13", "powershellEquivalent": "Portal or Graph API", - "recommendedBy": [ - "CIS" - ] + "recommendedBy": ["CIS"] }, { "name": "standards.AuthMethodsSettings", "cat": "Entra (AAD) Standards", - "tag": [ - "EIDSCA.AG01", - "EIDSCA.AG02", - "EIDSCA.AG03" - ], + "tag": ["EIDSCA.AG01", "EIDSCA.AG02", "EIDSCA.AG03", "EIDSCAAG02", "EIDSCAAG03"], "helpText": "Configures the report suspicious activity settings and system credential preferences in the authentication methods policy.", "docsDescription": "Controls the authentication methods policy settings for reporting suspicious activity and system credential preferences. These settings help enhance the security of authentication in your organization.", "executiveText": "Configures security settings that allow users to report suspicious login attempts and manages how the system handles authentication credentials. This enhances overall security by enabling early detection of potential security threats and optimizing authentication processes.", @@ -498,7 +470,7 @@ { "name": "standards.AuthMethodsPolicyMigration", "cat": "Entra (AAD) Standards", - "tag": [], + "tag": ["EIDSCAAG01"], "helpText": "Completes the migration of authentication methods policy to the new format", "docsDescription": "Sets the authentication methods policy migration state to complete. This is required when migrating from legacy authentication policies to the new unified authentication methods policy.", "executiveText": "Completes the transition from legacy authentication policies to Microsoft's modern unified authentication methods policy, ensuring the organization benefits from the latest security features and management capabilities. This migration enables enhanced security controls and simplified policy management.", @@ -508,9 +480,7 @@ "impactColour": "warning", "addedDate": "2025-07-07", "powershellEquivalent": "Update-MgBetaPolicyAuthenticationMethodPolicy", - "recommendedBy": [ - "CIPP" - ] + "recommendedBy": ["CIPP"] }, { "name": "standards.AppDeploy", @@ -579,7 +549,7 @@ { "name": "standards.laps", "cat": "Entra (AAD) Standards", - "tag": [], + "tag": ["ZTNA21953", "ZTNA21955", "ZTNA24560"], "helpText": "Enables the tenant to use LAPS. You must still create a policy for LAPS to be active on all devices. Use the template standards to deploy this by default.", "docsDescription": "Enables the LAPS functionality on the tenant. Prerequisite for using Windows LAPS via Azure AD.", "executiveText": "Enables Local Administrator Password Solution (LAPS) capability, which automatically manages and rotates local administrator passwords on company computers. This significantly improves security by preventing the use of shared or static administrator passwords that could be exploited by attackers.", @@ -589,9 +559,7 @@ "impactColour": "info", "addedDate": "2023-04-25", "powershellEquivalent": "Portal or Graph API", - "recommendedBy": [ - "CIPP" - ] + "recommendedBy": ["CIPP"] }, { "name": "standards.PWdisplayAppInformationRequiredState", @@ -604,7 +572,14 @@ "EIDSCA.AM07", "EIDSCA.AM09", "EIDSCA.AM10", - "NIST CSF 2.0 (PR.AA-03)" + "NIST CSF 2.0 (PR.AA-03)", + "EIDSCAAM01", + "EIDSCAAM03", + "EIDSCAAM04", + "EIDSCAAM06", + "EIDSCAAM07", + "EIDSCAAM09", + "EIDSCAAM10" ], "helpText": "Enables the MS authenticator app to display information about the app that is requesting authentication. This displays the application name.", "docsDescription": "Allows users to use Passwordless with Number Matching and adds location information from the last request", @@ -615,16 +590,12 @@ "impactColour": "info", "addedDate": "2021-11-16", "powershellEquivalent": "Update-MgBetaPolicyAuthenticationMethodPolicyAuthenticationMethodConfiguration", - "recommendedBy": [ - "CIS" - ] + "recommendedBy": ["CIS"] }, { "name": "standards.allowOTPTokens", "cat": "Entra (AAD) Standards", - "tag": [ - "EIDSCA.AM02" - ], + "tag": ["EIDSCA.AM02", "EIDSCAAM02"], "helpText": "Allows you to use MS authenticator OTP token generator", "docsDescription": "Allows you to use Microsoft Authenticator OTP token generator. Useful for using the NPS extension as MFA on VPN clients.", "executiveText": "Enables one-time password generation through Microsoft Authenticator app, providing an additional secure authentication method for employees. This is particularly useful for secure VPN access and other systems requiring multi-factor authentication.", @@ -639,9 +610,7 @@ { "name": "standards.PWcompanionAppAllowedState", "cat": "Entra (AAD) Standards", - "tag": [ - "EIDSCA.AM01" - ], + "tag": ["EIDSCA.AM01"], "helpText": "Sets the state of Authenticator Lite, Authenticator lite is a companion app for passwordless authentication.", "docsDescription": "Sets the Authenticator Lite state to enabled. This allows users to use the Authenticator Lite built into the Outlook app instead of the full Authenticator app.", "executiveText": "Enables a simplified authentication experience by allowing users to authenticate directly through Outlook without requiring a separate authenticator app. This improves user convenience while maintaining security standards for passwordless authentication.", @@ -685,7 +654,13 @@ "EIDSCA.AF04", "EIDSCA.AF05", "EIDSCA.AF06", - "NIST CSF 2.0 (PR.AA-03)" + "NIST CSF 2.0 (PR.AA-03)", + "EIDSCAAF01", + "EIDSCAAF02", + "EIDSCAAF03", + "EIDSCAAF04", + "EIDSCAAF05", + "EIDSCAAF06" ], "helpText": "Enables the FIDO2 authenticationMethod for the tenant", "docsDescription": "Enables FIDO2 capabilities for the tenant. This allows users to use FIDO2 keys like a Yubikey for authentication.", @@ -696,9 +671,7 @@ "impactColour": "info", "addedDate": "2022-12-08", "powershellEquivalent": "Update-MgBetaPolicyAuthenticationMethodPolicyAuthenticationMethodConfiguration", - "recommendedBy": [ - "CIPP" - ] + "recommendedBy": ["CIPP"] }, { "name": "standards.EnableHardwareOAuth", @@ -718,10 +691,7 @@ { "name": "standards.allowOAuthTokens", "cat": "Entra (AAD) Standards", - "tag": [ - "EIDSCA.AT01", - "EIDSCA.AT02" - ], + "tag": ["EIDSCA.AT01", "EIDSCA.AT02"], "helpText": "Allows you to use any software OAuth token generator", "docsDescription": "Enables OTP Software OAuth tokens for the tenant. This allows users to use OTP codes generated via software, like a password manager to be used as an authentication method.", "executiveText": "Allows employees to use third-party authentication apps and password managers to generate secure login codes, providing flexibility in authentication methods while maintaining security standards. This accommodates diverse user preferences and existing security tools.", @@ -736,11 +706,7 @@ { "name": "standards.FormsPhishingProtection", "cat": "Global Standards", - "tag": [ - "CIS M365 5.0 (1.3.5)", - "Security", - "PhishingProtection" - ], + "tag": ["CIS M365 5.0 (1.3.5)", "Security", "PhishingProtection"], "helpText": "Enables internal phishing protection for Microsoft Forms to help prevent malicious forms from being created and shared within the organization. This feature scans forms created by internal users for potential phishing content and suspicious patterns.", "docsDescription": "Enables internal phishing protection for Microsoft Forms by setting the isInOrgFormsPhishingScanEnabled property to true. This security feature helps protect organizations from internal phishing attacks through Microsoft Forms by automatically scanning forms created by internal users for potential malicious content, suspicious links, and phishing patterns. When enabled, Forms will analyze form content and block or flag potentially dangerous forms before they can be shared within the organization.", "executiveText": "Automatically scans Microsoft Forms created by employees for malicious content and phishing attempts, preventing the creation and distribution of harmful forms within the organization. This protects against both internal threats and compromised accounts that might be used to distribute malicious content.", @@ -750,15 +716,12 @@ "impactColour": "info", "addedDate": "2025-06-06", "powershellEquivalent": "Graph API", - "recommendedBy": [ - "CIS", - "CIPP" - ] + "recommendedBy": ["CIS", "CIPP"] }, { "name": "standards.TAP", "cat": "Entra (AAD) Standards", - "tag": [], + "tag": ["ZTNA21845", "ZTNA21846", "EIDSCAAT01", "EIDSCAAT02"], "helpText": "Enables TAP and sets the default TAP lifetime to 1 hour. This configuration also allows you to select if a TAP is single use or multi-logon.", "docsDescription": "Enables Temporary Password generation for the tenant.", "executiveText": "Enables temporary access passwords that IT administrators can generate for employees who are locked out or need emergency access to systems. These time-limited passwords provide a secure way to restore access without compromising long-term security policies.", @@ -786,17 +749,12 @@ "impactColour": "info", "addedDate": "2022-03-15", "powershellEquivalent": "Update-MgBetaPolicyAuthenticationMethodPolicyAuthenticationMethodConfiguration", - "recommendedBy": [ - "CIPP" - ] + "recommendedBy": ["CIPP"] }, { "name": "standards.PasswordExpireDisabled", "cat": "Entra (AAD) Standards", - "tag": [ - "CIS M365 5.0 (1.3.1)", - "PWAgePolicyNew" - ], + "tag": ["CIS M365 5.0 (1.3.1)", "PWAgePolicyNew"], "helpText": "Disables the expiration of passwords for the tenant by setting the password expiration policy to never expire for any user.", "docsDescription": "Sets passwords to never expire for tenant, recommended to use in conjunction with secure password requirements.", "executiveText": "Eliminates mandatory password expiration requirements, allowing employees to keep strong passwords indefinitely rather than forcing frequent changes that often lead to weaker passwords. This modern security approach reduces help desk calls and improves overall password security when combined with multi-factor authentication.", @@ -806,16 +764,21 @@ "impactColour": "info", "addedDate": "2021-11-16", "powershellEquivalent": "Update-MgDomain", - "recommendedBy": [ - "CIS", - "CIPP" - ] + "recommendedBy": ["CIS", "CIPP"] }, { "name": "standards.CustomBannedPasswordList", "cat": "Entra (AAD) Standards", "tag": [ - "CIS M365 5.0 (5.2.3.2)" + "CIS M365 5.0 (5.2.3.2)", + "ZTNA21848", + "ZTNA21849", + "ZTNA21850", + "EIDSCAPR01", + "EIDSCAPR02", + "EIDSCAPR03", + "EIDSCAPR05", + "EIDSCAPR06" ], "helpText": "**Requires Entra ID P1.** Updates and enables the Entra ID custom banned password list with the supplied words. Enter words separated by commas or semicolons. Each word must be 4-16 characters long. Maximum 1,000 words allowed.", "docsDescription": "Updates and enables the Entra ID custom banned password list with the supplied words. This supplements the global banned password list maintained by Microsoft. The custom list is limited to 1,000 key base terms of 4-16 characters each. Entra ID will [block variations and common substitutions](https://learn.microsoft.com/en-us/entra/identity/authentication/tutorial-configure-custom-password-protection#configure-custom-banned-passwords) of these words in user passwords. [How are passwords evaluated?](https://learn.microsoft.com/en-us/entra/identity/authentication/concept-password-ban-bad#score-calculation)", @@ -833,14 +796,12 @@ "impactColour": "warning", "addedDate": "2025-06-28", "powershellEquivalent": "Get-MgBetaDirectorySetting, New-MgBetaDirectorySetting, Update-MgBetaDirectorySetting", - "recommendedBy": [ - "CIS" - ] + "recommendedBy": ["CIS"] }, { "name": "standards.ExternalMFATrusted", "cat": "Entra (AAD) Standards", - "tag": [], + "tag": ["ZTNA21803", "ZTNA21804"], "helpText": "Sets the state of the Cross-tenant access setting to trust external MFA. This allows guest users to use their home tenant MFA to access your tenant.", "executiveText": "Allows external partners and vendors to use their own organization's multi-factor authentication when accessing company resources, streamlining collaboration while maintaining security standards. This reduces friction for external users while ensuring they still meet authentication requirements.", "addedComponent": [ @@ -872,10 +833,7 @@ { "name": "standards.DisableTenantCreation", "cat": "Entra (AAD) Standards", - "tag": [ - "CIS M365 5.0 (1.2.3)", - "CISA (MS.AAD.6.1v1)" - ], + "tag": ["CIS M365 5.0 (1.2.3)", "CISA (MS.AAD.6.1v1)", "ZTNA21772", "ZTNA21787"], "helpText": "Restricts creation of M365 tenants to the Global Administrator or Tenant Creator roles.", "docsDescription": "Users by default are allowed to create M365 tenants. This disables that so only admins can create new M365 tenants.", "executiveText": "Prevents regular employees from creating new Microsoft 365 organizations, ensuring all new tenants are properly managed and controlled by IT administrators. This prevents unauthorized shadow IT environments and maintains centralized governance over Microsoft 365 resources.", @@ -885,10 +843,7 @@ "impactColour": "info", "addedDate": "2022-11-29", "powershellEquivalent": "Update-MgPolicyAuthorizationPolicy", - "recommendedBy": [ - "CIS", - "CIPP" - ] + "recommendedBy": ["CIS", "CIPP"] }, { "name": "standards.EnableAppConsentRequests", @@ -902,7 +857,12 @@ "EIDSCA.CR03", "EIDSCA.CR04", "Essential 8 (1507)", - "NIST CSF 2.0 (PR.AA-05)" + "NIST CSF 2.0 (PR.AA-05)", + "ZTNA21869", + "EIDSCACR01", + "EIDSCACR02", + "EIDSCACR03", + "EIDSCACR04" ], "helpText": "Enables App consent admin requests for the tenant via the GA role. Does not overwrite existing reviewer settings", "docsDescription": "Enables the ability for users to request admin consent for applications. Should be used in conjunction with the \"Require admin consent for applications\" standards", @@ -919,14 +879,12 @@ "impactColour": "info", "addedDate": "2023-11-27", "powershellEquivalent": "Update-MgPolicyAdminConsentRequestPolicy", - "recommendedBy": [ - "CIS" - ] + "recommendedBy": ["CIS"] }, { "name": "standards.NudgeMFA", "cat": "Entra (AAD) Standards", - "tag": [], + "tag": ["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.", "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.", @@ -952,7 +910,11 @@ "type": "number", "name": "standards.NudgeMFA.snoozeDurationInDays", "label": "Number of days to allow users to skip registering Authenticator (0-14, default is 1)", - "defaultValue": 1 + "defaultValue": 1, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 14, "message": "Maximum value is 14" } + } } ], "label": "Sets the state for the request to setup Authenticator", @@ -965,9 +927,7 @@ { "name": "standards.DisableM365GroupUsers", "cat": "Entra (AAD) Standards", - "tag": [ - "CISA (MS.AAD.21.1v1)" - ], + "tag": ["CISA (MS.AAD.21.1v1)", "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.", @@ -987,7 +947,8 @@ "CISA (MS.AAD.4.1v1)", "EIDSCA.AP10", "Essential 8 (1175)", - "NIST CSF 2.0 (PR.AA-05)" + "NIST CSF 2.0 (PR.AA-05)", + "EIDSCAAP10" ], "helpText": "Disables the ability for users to create App registrations in the tenant.", "docsDescription": "Disables the ability for users to create applications in Entra. Done to prevent breached accounts from creating an app to maintain access to the tenant, even after the breached account has been secured.", @@ -998,15 +959,12 @@ "impactColour": "info", "addedDate": "2024-03-20", "powershellEquivalent": "Update-MgPolicyAuthorizationPolicy", - "recommendedBy": [ - "CIS", - "CIPP" - ] + "recommendedBy": ["CIS", "CIPP"] }, { "name": "standards.BitLockerKeysForOwnedDevice", "cat": "Entra (AAD) Standards", - "tag": [], + "tag": ["ZTNA21954"], "helpText": "Controls whether standard users can recover BitLocker keys for devices they own.", "docsDescription": "Updates the Microsoft Entra authorization policy that controls whether standard users can read BitLocker recovery keys for devices they own. Choose to restrict access for tighter security or allow self-service recovery when operational needs require it.", "executiveText": "Gives administrators centralized control over BitLocker recovery secrets—restrict access to ensure IT-assisted recovery flows, or allow self-service when rapid device unlocks are a priority.", @@ -1039,10 +997,7 @@ { "name": "standards.DisableSecurityGroupUsers", "cat": "Entra (AAD) Standards", - "tag": [ - "CISA (MS.AAD.20.1v1)", - "NIST CSF 2.0 (PR.AA-05)" - ], + "tag": ["CISA (MS.AAD.20.1v1)", "NIST CSF 2.0 (PR.AA-05)", "ZTNA21868"], "helpText": "Completely disables the creation of security groups by users. This also breaks the ability to manage groups themselves, or create Teams", "executiveText": "Restricts the creation of security groups to IT administrators only, preventing employees from creating unauthorized access groups that could bypass security controls. This ensures proper governance of access permissions and maintains centralized control over who can access what resources.", "addedComponent": [], @@ -1071,7 +1026,7 @@ "name": "standards.DisableSelfServiceLicenses", "cat": "Entra (AAD) Standards", "tag": [], - "helpText": "Note: requires 'Billing Administrator' GDAP role. This standard disables all self service licenses and enables all exclusions", + "helpText": "**Requires 'Billing Administrator' GDAP role.** This standard disables all self service licenses and enables all exclusions", "executiveText": "Prevents employees from purchasing Microsoft 365 licenses independently, ensuring all software acquisitions go through proper procurement channels. This maintains budget control, prevents unauthorized spending, and ensures compliance with corporate licensing agreements.", "addedComponent": [ { @@ -1091,7 +1046,7 @@ { "name": "standards.DisableGuests", "cat": "Entra (AAD) Standards", - "tag": [], + "tag": ["ZTNA21858"], "helpText": "Blocks login for guest users that have not logged in for a number of days", "executiveText": "Automatically disables external guest accounts that haven't been used for a number of days, reducing security risks from dormant accounts while maintaining access for active external collaborators. This helps maintain a clean user directory and reduces potential attack vectors.", "addedComponent": [ @@ -1108,10 +1063,7 @@ "impactColour": "warning", "addedDate": "2022-10-20", "powershellEquivalent": "Graph API", - "recommendedBy": [ - "CIS", - "CIPP" - ] + "recommendedBy": ["CIS", "CIPP"] }, { "name": "standards.OauthConsent", @@ -1122,7 +1074,15 @@ "EIDSCA.AP08", "EIDSCA.AP09", "Essential 8 (1175)", - "NIST CSF 2.0 (PR.AA-05)" + "NIST CSF 2.0 (PR.AA-05)", + "ZTNA21772", + "ZTNA21774", + "ZTNA21807", + "EIDSCAAP08", + "EIDSCAAP09", + "EIDSCACP01", + "EIDSCACP03", + "EIDSCACP04" ], "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.", @@ -1140,17 +1100,12 @@ "impactColour": "warning", "addedDate": "2021-11-16", "powershellEquivalent": "Update-MgPolicyAuthorizationPolicy", - "recommendedBy": [ - "CIS", - "CIPP" - ] + "recommendedBy": ["CIS", "CIPP"] }, { "name": "standards.OauthConsentLowSec", "cat": "Entra (AAD) Standards", - "tag": [ - "IntegratedApps" - ], + "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.", "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.", @@ -1164,11 +1119,7 @@ { "name": "standards.GuestInvite", "cat": "Entra (AAD) Standards", - "tag": [ - "CISA (MS.AAD.18.1v1)", - "EIDSCA.AP04", - "EIDSCA.AP07" - ], + "tag": ["CISA (MS.AAD.18.1v1)", "EIDSCA.AP04", "EIDSCA.AP07", "EIDSCAAP04"], "helpText": "This setting controls who can invite guests to your directory to collaborate on resources secured by your company, such as SharePoint sites or Azure resources.", "executiveText": "Controls who within the organization can invite external partners and vendors to access company resources, ensuring proper oversight of external access while enabling necessary business collaboration. This helps maintain security while supporting partnership and vendor relationships.", "addedComponent": [ @@ -1209,25 +1160,24 @@ { "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.", + "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)", "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)" + "label": "Days before stale(Do not set below 30)", + "validators": { + "min": { "value": 30, "message": "Minimum value is 30" } + } } ], "disabledFeatures": { "report": false, "warn": false, - "remediate": false + "remediate": true }, "label": "Cleanup stale Entra devices", "impact": "High Impact", @@ -1253,9 +1203,7 @@ { "name": "standards.SecurityDefaults", "cat": "Entra (AAD) Standards", - "tag": [ - "CISA (MS.AAD.11.1v1)" - ], + "tag": ["CISA (MS.AAD.11.1v1)", "ZTNA21843"], "helpText": "Enables security defaults for the tenant, for newer tenants this is enabled by default. Do not enable this feature if you use Conditional Access.", "docsDescription": "Enables SD for the tenant, which disables all forms of basic authentication and enforces users to configure MFA. Users are only prompted for MFA when a logon is considered 'suspect' by Microsoft.", "executiveText": "Activates Microsoft's baseline security configuration that requires multi-factor authentication and blocks legacy authentication methods. This provides essential security protection for organizations without complex conditional access policies, significantly improving security posture with minimal configuration.", @@ -1270,11 +1218,7 @@ { "name": "standards.DisableSMS", "cat": "Entra (AAD) Standards", - "tag": [ - "CIS M365 5.0 (2.3.5)", - "EIDSCA.AS04", - "NIST CSF 2.0 (PR.AA-03)" - ], + "tag": ["CIS M365 5.0 (2.3.5)", "EIDSCA.AS04", "NIST CSF 2.0 (PR.AA-03)", "EIDSCAAS04"], "helpText": "This blocks users from using SMS as an MFA method. If a user only has SMS as a MFA method, they will be unable to log in.", "docsDescription": "Disables SMS as an MFA method for the tenant. If a user only has SMS as a MFA method, they will be unable to sign in.", "executiveText": "Disables SMS text messages as a multi-factor authentication method due to security vulnerabilities like SIM swapping attacks. This forces users to adopt more secure authentication methods like authenticator apps or hardware tokens, significantly improving account security.", @@ -1284,18 +1228,12 @@ "impactColour": "danger", "addedDate": "2023-12-18", "powershellEquivalent": "Update-MgBetaPolicyAuthenticationMethodPolicyAuthenticationMethodConfiguration", - "recommendedBy": [ - "CIPP" - ] + "recommendedBy": ["CIPP"] }, { "name": "standards.DisableVoice", "cat": "Entra (AAD) Standards", - "tag": [ - "CIS M365 5.0 (2.3.5)", - "EIDSCA.AV01", - "NIST CSF 2.0 (PR.AA-03)" - ], + "tag": ["CIS M365 5.0 (2.3.5)", "EIDSCA.AV01", "NIST CSF 2.0 (PR.AA-03)", "EIDSCAAV01"], "helpText": "This blocks users from using Voice call as an MFA method. If a user only has Voice as a MFA method, they will be unable to log in.", "docsDescription": "Disables Voice call as an MFA method for the tenant. If a user only has Voice call as a MFA method, they will be unable to sign in.", "executiveText": "Disables voice call authentication due to security vulnerabilities and social engineering risks. This forces users to adopt more secure authentication methods like authenticator apps, improving overall account security by eliminating phone-based attack vectors.", @@ -1305,17 +1243,12 @@ "impactColour": "danger", "addedDate": "2023-12-18", "powershellEquivalent": "Update-MgBetaPolicyAuthenticationMethodPolicyAuthenticationMethodConfiguration", - "recommendedBy": [ - "CIPP" - ] + "recommendedBy": ["CIPP"] }, { "name": "standards.DisableEmail", "cat": "Entra (AAD) Standards", - "tag": [ - "CIS M365 5.0 (2.3.5)", - "NIST CSF 2.0 (PR.AA-03)" - ], + "tag": ["CIS M365 5.0 (2.3.5)", "NIST CSF 2.0 (PR.AA-03)"], "helpText": "This blocks users from using email as an MFA method. This disables the email OTP option for guest users, and instead prompts them to create a Microsoft account.", "executiveText": "Disables email-based authentication codes due to security concerns with email interception and account compromise. This forces users to adopt more secure authentication methods, particularly affecting guest users who must use stronger verification methods.", "addedComponent": [], @@ -1368,7 +1301,10 @@ "Essential 8 (1504)", "Essential 8 (1173)", "Essential 8 (1401)", - "NIST CSF 2.0 (PR.AA-03)" + "NIST CSF 2.0 (PR.AA-03)", + "ZTNA21780", + "ZTNA21782", + "ZTNA21796" ], "helpText": "Enables per user MFA for all users.", "executiveText": "Requires all employees to use multi-factor authentication for enhanced account security, significantly reducing the risk of unauthorized access from compromised passwords. This fundamental security measure protects against the majority of account-based attacks and is essential for maintaining strong cybersecurity posture.", @@ -1410,9 +1346,7 @@ { "name": "standards.OutBoundSpamAlert", "cat": "Exchange Standards", - "tag": [ - "CIS M365 5.0 (2.1.6)" - ], + "tag": ["CIS M365 5.0 (2.1.6)"], "helpText": "Set the Outbound Spam Alert e-mail address", "docsDescription": "Sets the e-mail address to which outbound spam alerts are sent.", "addedComponent": [ @@ -1427,9 +1361,7 @@ "impactColour": "info", "addedDate": "2023-05-03", "powershellEquivalent": "Set-HostedOutboundSpamFilterPolicy", - "recommendedBy": [ - "CIS" - ] + "recommendedBy": ["CIS"] }, { "name": "standards.MessageExpiration", @@ -1493,9 +1425,7 @@ "impactColour": "info", "addedDate": "2024-04-26", "powershellEquivalent": "Set-RemoteDomain -Identity 'Default' -TNEFEnabled $false", - "recommendedBy": [ - "CIPP" - ] + "recommendedBy": ["CIPP"] }, { "name": "standards.FocusedInbox", @@ -1612,10 +1542,7 @@ { "name": "standards.EnableOnlineArchiving", "cat": "Exchange Standards", - "tag": [ - "Essential 8 (1511)", - "NIST CSF 2.0 (PR.DS-11)" - ], + "tag": ["Essential 8 (1511)", "NIST CSF 2.0 (PR.DS-11)"], "helpText": "Enables the In-Place Online Archive for all UserMailboxes with a valid license.", "executiveText": "Automatically enables online email archiving for all licensed employees, providing additional storage for older emails while maintaining easy access. This helps manage mailbox sizes, improves email performance, and supports compliance with data retention requirements.", "addedComponent": [], @@ -1650,9 +1577,7 @@ { "name": "standards.SpoofWarn", "cat": "Exchange Standards", - "tag": [ - "CIS M365 5.0 (6.2.3)" - ], + "tag": ["CIS M365 5.0 (6.2.3)", "ORCA111", "ORCA240", "CISAMSEXO71"], "helpText": "Adds or removes indicators to e-mail messages received from external senders in Outlook. Works on all Outlook clients/OWA", "docsDescription": "Adds or removes indicators to e-mail messages received from external senders in Outlook. You can read more about this feature on [Microsoft's Exchange Team Blog.](https://techcommunity.microsoft.com/t5/exchange-team-blog/native-external-sender-callouts-on-email-in-outlook/ba-p/2250098)", "executiveText": "Displays visual warnings in Outlook when emails come from external senders, helping employees identify potentially suspicious messages and reducing the risk of phishing attacks. This security feature makes it easier for staff to distinguish between internal and external communications.", @@ -1687,18 +1612,12 @@ "impactColour": "info", "addedDate": "2021-11-16", "powershellEquivalent": "Set-ExternalInOutlook \u2013Enabled $true or $false", - "recommendedBy": [ - "CIS", - "CIPP" - ] + "recommendedBy": ["CIS", "CIPP"] }, { "name": "standards.EnableMailTips", "cat": "Exchange Standards", - "tag": [ - "CIS M365 5.0 (6.5.2)", - "exo_mailtipsenabled" - ], + "tag": ["CIS M365 5.0 (6.5.2)", "exo_mailtipsenabled"], "helpText": "Enables all MailTips in Outlook. MailTips are the notifications Outlook and Outlook on the web shows when an email you create, meets some requirements", "executiveText": "Enables helpful notifications in Outlook that warn users about potential email issues, such as sending to large groups, external recipients, or invalid addresses. This reduces email mistakes and improves communication efficiency by providing real-time guidance to employees.", "addedComponent": [ @@ -1715,10 +1634,7 @@ "impactColour": "info", "addedDate": "2024-01-14", "powershellEquivalent": "Set-OrganizationConfig", - "recommendedBy": [ - "CIS", - "CIPP" - ] + "recommendedBy": ["CIS", "CIPP"] }, { "name": "standards.TeamsMeetingsByDefault", @@ -1770,9 +1686,7 @@ { "name": "standards.RotateDKIM", "cat": "Exchange Standards", - "tag": [ - "CIS M365 5.0 (2.1.9)" - ], + "tag": ["CIS M365 5.0 (2.1.9)"], "helpText": "Rotate DKIM keys that are 1024 bit to 2048 bit", "executiveText": "Upgrades email security by replacing older 1024-bit encryption keys with stronger 2048-bit keys for email authentication. This improves the organization's email security posture and helps prevent email spoofing and tampering, maintaining trust with email recipients.", "addedComponent": [], @@ -1781,17 +1695,12 @@ "impactColour": "info", "addedDate": "2023-03-14", "powershellEquivalent": "Rotate-DkimSigningConfig", - "recommendedBy": [ - "CIS", - "CIPP" - ] + "recommendedBy": ["CIS", "CIPP"] }, { "name": "standards.AddDKIM", "cat": "Exchange Standards", - "tag": [ - "CIS M365 5.0 (2.1.9)" - ], + "tag": ["CIS M365 5.0 (2.1.9)", "ORCA108", "CISAMSEXO31"], "helpText": "Enables DKIM for all domains that currently support it", "executiveText": "Enables email authentication technology that digitally signs outgoing emails to verify they actually came from your organization. This prevents email spoofing, improves email deliverability, and protects the company's reputation by ensuring recipients can trust emails from your domains.", "addedComponent": [], @@ -1800,19 +1709,12 @@ "impactColour": "info", "addedDate": "2023-03-14", "powershellEquivalent": "New-DkimSigningConfig and Set-DkimSigningConfig", - "recommendedBy": [ - "CIS", - "CIPP" - ] + "recommendedBy": ["CIS", "CIPP"] }, { "name": "standards.AddDMARCToMOERA", "cat": "Global Standards", - "tag": [ - "CIS M365 5.0 (2.1.10)", - "Security", - "PhishingProtection" - ], + "tag": ["CIS M365 5.0 (2.1.10)", "Security", "PhishingProtection"], "helpText": "Note: requires 'Domain Name Administrator' GDAP role. This should be enabled even if the MOERA (onmicrosoft.com) domains is not used for sending. Enabling this prevents email spoofing. The default value is 'v=DMARC1; p=reject;' recommended because the domain is only used within M365 and reporting is not needed. Omitting pct tag default to 100%", "docsDescription": "Note: requires 'Domain Name Administrator' GDAP role. Adds a DMARC record to MOERA (onmicrosoft.com) domains. This should be enabled even if the MOERA (onmicrosoft.com) domains is not used for sending. Enabling this prevents email spoofing. The default record is 'v=DMARC1; p=reject;' recommended because the domain is only used within M365 and reporting is not needed. Omitting pct tag default to 100%", "executiveText": "Implements advanced email security for Microsoft's default domain names (onmicrosoft.com) to prevent criminals from impersonating your organization. This blocks fraudulent emails that could damage your company's reputation and protects partners and customers from phishing attacks using your domain names.", @@ -1838,10 +1740,7 @@ "impactColour": "info", "addedDate": "2025-06-16", "powershellEquivalent": "Portal only", - "recommendedBy": [ - "CIS", - "Microsoft" - ] + "recommendedBy": ["CIS", "Microsoft"] }, { "name": "standards.EnableMailboxAuditing", @@ -1853,7 +1752,8 @@ "exo_mailboxaudit", "Essential 8 (1509)", "Essential 8 (1683)", - "NIST CSF 2.0 (DE.CM-09)" + "NIST CSF 2.0 (DE.CM-09)", + "CISAMSEXO131" ], "helpText": "Enables Mailbox auditing for all mailboxes and on tenant level. Disables audit bypass on all mailboxes. Unified Audit Log needs to be enabled for this standard to function.", "docsDescription": "Enables mailbox auditing on tenant level and for all mailboxes. Disables audit bypass on all mailboxes. By default Microsoft does not enable mailbox auditing for Resource Mailboxes, Public Folder Mailboxes and DiscoverySearch Mailboxes. Unified Audit Log needs to be enabled for this standard to function.", @@ -1864,10 +1764,66 @@ "impactColour": "info", "addedDate": "2024-01-08", "powershellEquivalent": "Set-OrganizationConfig -AuditDisabled $false", - "recommendedBy": [ - "CIS", - "CIPP" - ] + "recommendedBy": ["CIS", "CIPP"] + }, + { + "name": "standards.AutoArchive", + "cat": "Exchange Standards", + "tag": [], + "helpText": "Configures the auto-archiving threshold percentage for the tenant. When a mailbox exceeds this threshold, the oldest items are automatically moved to the archive mailbox. Archive must be enabled manually or via the CIPP standard 'Enable Online Archive for all users'. More information can be found in [Microsoft's documentation.](https://learn.microsoft.com/en-us/exchange/security-and-compliance/messaging-records-management/auto-archiving)", + "docsDescription": "Configures the auto-archiving threshold at the organization level. Auto-archiving automatically moves the oldest items from a user's primary mailbox to their archive mailbox when mailbox usage exceeds the configured threshold percentage. This prevents mail flow disruptions caused by full mailboxes. Valid range is 80-100, where 100 disables auto-archiving for the tenant. More information can be found in [Microsoft's documentation.](https://learn.microsoft.com/en-us/exchange/security-and-compliance/messaging-records-management/auto-archiving)", + "executiveText": "Configures automatic archiving of mailbox items when storage approaches capacity, preventing email delivery failures due to full mailboxes. This proactive storage management ensures business continuity and reduces helpdesk tickets related to mailbox quota issues.", + "addedComponent": [ + { + "type": "number", + "name": "standards.AutoArchive.AutoArchivingThresholdPercentage", + "label": "Auto-Archiving Threshold Percentage (80-100, default 96, 100 disables)", + "defaultValue": 96, + "validators": { + "min": { "value": 80, "message": "Minimum value is 80" }, + "max": { "value": 100, "message": "Maximum value is 100" } + } + } + ], + "label": "Configure Auto-Archiving Threshold", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2025-12-11", + "powershellEquivalent": "Set-OrganizationConfig -AutoArchivingThresholdPercentage 80-100", + "recommendedBy": [] + }, + { + "name": "standards.AutoArchiveMailbox", + "cat": "Exchange Standards", + "tag": [], + "helpText": "Enables or disables the tenant policy that automatically provisions an archive mailbox when a user's primary mailbox reaches 90% of its quota.", + "docsDescription": "Enables or disables the tenant policy that automatically provisions an archive mailbox when a user's primary mailbox reaches 90% of its quota. This is separate from auto-archiving thresholds and does not enable archives for all users immediately.", + "executiveText": "Automatically provisions archive mailboxes only when users reach 90% of their mailbox capacity, reducing manual intervention and preventing mailbox quota issues without enabling archives for everyone.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Select value", + "name": "standards.AutoArchiveMailbox.state", + "options": [ + { + "label": "Enabled", + "value": "enabled" + }, + { + "label": "Disabled", + "value": "disabled" + } + ] + } + ], + "label": "Set auto enable archive mailbox state", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2026-01-16", + "powershellEquivalent": "Set-OrganizationConfig -AutoEnableArchiveMailbox $true|$false", + "recommendedBy": [] }, { "name": "standards.SendReceiveLimitTenant", @@ -1880,13 +1836,21 @@ "type": "number", "name": "standards.SendReceiveLimitTenant.SendLimit", "label": "Send limit in MB (Default is 35)", - "defaultValue": 35 + "defaultValue": 35, + "validators": { + "min": { "value": 1, "message": "Minimum value is 1" }, + "max": { "value": 150, "message": "Maximum value is 150" } + } }, { "type": "number", "name": "standards.SendReceiveLimitTenant.ReceiveLimit", "label": "Receive Limit in MB (Default is 36)", - "defaultValue": 36 + "defaultValue": 36, + "validators": { + "min": { "value": 1, "message": "Minimum value is 1" }, + "max": { "value": 150, "message": "Maximum value is 150" } + } } ], "label": "Set send/receive size limits", @@ -1972,9 +1936,7 @@ { "name": "standards.EXOOutboundSpamLimits", "cat": "Exchange Standards", - "tag": [ - "CIS M365 5.0 (2.1.6)" - ], + "tag": ["CIS M365 5.0 (2.1.6)"], "helpText": "Configures the outbound spam recipient limits (external per hour, internal per hour, per day) and the action to take when a limit is reached. The 'Set Outbound Spam Alert e-mail' standard is recommended to configure together with this one. ", "docsDescription": "Configures the Exchange Online outbound spam recipient limits for external per hour, internal per hour, and per day, along with the action to take (e.g., BlockUser, Alert) when these limits are exceeded. This helps prevent abuse and manage email flow. Microsoft's recommendations can be found [here.](https://learn.microsoft.com/en-us/defender-office-365/recommended-settings-for-eop-and-office365#eop-outbound-spam-policy-settings) The 'Set Outbound Spam Alert e-mail' standard is recommended to configure together with this one.", "executiveText": "Sets limits on how many emails employees can send per hour and per day to prevent spam and protect the organization's email reputation. When limits are exceeded, the system can alert administrators or temporarily block the user, helping detect compromised accounts or prevent abuse.", @@ -1983,19 +1945,31 @@ "type": "number", "name": "standards.EXOOutboundSpamLimits.RecipientLimitExternalPerHour", "label": "External Recipient Limit Per Hour", - "defaultValue": 400 + "defaultValue": 400, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 10000, "message": "Maximum value is 10000" } + } }, { "type": "number", "name": "standards.EXOOutboundSpamLimits.RecipientLimitInternalPerHour", "label": "Internal Recipient Limit Per Hour", - "defaultValue": 800 + "defaultValue": 800, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 10000, "message": "Maximum value is 10000" } + } }, { "type": "number", "name": "standards.EXOOutboundSpamLimits.RecipientLimitPerDay", "label": "Daily Recipient Limit", - "defaultValue": 800 + "defaultValue": 800, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 10000, "message": "Maximum value is 10000" } + } }, { "type": "autoComplete", @@ -2024,18 +1998,12 @@ "impactColour": "info", "addedDate": "2025-05-13", "powershellEquivalent": "Set-HostedOutboundSpamFilterPolicy", - "recommendedBy": [ - "CIPP", - "CIS" - ] + "recommendedBy": ["CIPP", "CIS"] }, { "name": "standards.DisableExternalCalendarSharing", "cat": "Exchange Standards", - "tag": [ - "CIS M365 5.0 (1.3.3)", - "exo_individualsharing" - ], + "tag": ["CIS M365 5.0 (1.3.3)", "exo_individualsharing", "ZTNA21803", "CISAMSEXO62"], "helpText": "Disables the ability for users to share their calendar with external users. Only for the default policy, so exclusions can be made if needed.", "docsDescription": "Disables external calendar sharing for the entire tenant. This is not a widely used feature, and it's therefore unlikely that this will impact users. Only for the default policy, so exclusions can be made if needed by making a new policy and assigning it to users.", "executiveText": "Prevents employees from sharing their calendars with external parties, protecting sensitive meeting information and internal schedules from unauthorized access. This security measure helps maintain confidentiality of business activities while still allowing internal collaboration.", @@ -2045,9 +2013,7 @@ "impactColour": "info", "addedDate": "2024-01-08", "powershellEquivalent": "Get-SharingPolicy | Set-SharingPolicy -Enabled $False", - "recommendedBy": [ - "CIS" - ] + "recommendedBy": ["CIS"] }, { "name": "standards.AutoAddProxy", @@ -2072,10 +2038,7 @@ { "name": "standards.DisableAdditionalStorageProviders", "cat": "Exchange Standards", - "tag": [ - "CIS M365 5.0 (6.5.3)", - "exo_storageproviderrestricted" - ], + "tag": ["CIS M365 5.0 (6.5.3)", "exo_storageproviderrestricted", "ZTNA21817"], "helpText": "Disables the ability for users to open files in Outlook on the Web, from other providers such as Box, Dropbox, Facebook, Google Drive, OneDrive Personal, etc.", "docsDescription": "Disables additional storage providers in OWA. This is to prevent users from using personal storage providers like Dropbox, Google Drive, etc. Usually this has little user impact.", "executiveText": "Prevents employees from accessing personal cloud storage services like Dropbox or Google Drive through Outlook on the web, reducing data security risks and ensuring company information stays within approved corporate systems. This helps maintain data governance and prevents accidental data leaks.", @@ -2085,16 +2048,12 @@ "impactColour": "info", "addedDate": "2024-01-17", "powershellEquivalent": "Get-OwaMailboxPolicy | Set-OwaMailboxPolicy -AdditionalStorageProvidersEnabled $False", - "recommendedBy": [ - "CIS" - ] + "recommendedBy": ["CIS"] }, { "name": "standards.AntiSpamSafeList", "cat": "Defender Standards", - "tag": [ - "CIS M365 5.0 (2.1.13)" - ], + "tag": ["CIS M365 5.0 (2.1.13)"], "helpText": "Sets the anti-spam connection filter policy option 'safe list' in Defender.", "docsDescription": "Sets [Microsoft's built-in 'safe list'](https://learn.microsoft.com/en-us/powershell/module/exchange/set-hostedconnectionfilterpolicy?view=exchange-ps#-enablesafelist) in the anti-spam connection filter policy, rather than setting a custom safe/block list of IPs.", "executiveText": "Enables Microsoft's pre-approved list of trusted email servers to improve email delivery from legitimate sources while maintaining spam protection. This reduces false positives where legitimate emails might be blocked while still protecting against spam and malicious emails.", @@ -2143,13 +2102,21 @@ "type": "number", "name": "standards.ShortenMeetings.DefaultMinutesToReduceShortEventsBy", "label": "Minutes to reduce short calendar events by (Default is 5)", - "defaultValue": 5 + "defaultValue": 5, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 29, "message": "Maximum value is 29" } + } }, { "type": "number", "name": "standards.ShortenMeetings.DefaultMinutesToReduceLongEventsBy", "label": "Minutes to reduce long calendar events by (Default is 10)", - "defaultValue": 10 + "defaultValue": 10, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 29, "message": "Maximum value is 29" } + } } ], "label": "Set shorten meetings state", @@ -2231,7 +2198,8 @@ "CIS M365 5.0 (6.3.1)", "exo_outlookaddins", "NIST CSF 2.0 (PR.AA-05)", - "NIST CSF 2.0 (PR.PS-05)" + "NIST CSF 2.0 (PR.PS-05)", + "ZTNA21817" ], "helpText": "Disables the ability for users to install add-ins in Outlook. This is to prevent users from installing malicious add-ins.", "docsDescription": "Disables users from being able to install add-ins in Outlook. Only admins are able to approve add-ins for the users. This is done to reduce the threat surface for data exfiltration.", @@ -2242,9 +2210,7 @@ "impactColour": "warning", "addedDate": "2024-02-05", "powershellEquivalent": "Get-ManagementRoleAssignment | Remove-ManagementRoleAssignment", - "recommendedBy": [ - "CIS" - ] + "recommendedBy": ["CIS"] }, { "name": "standards.SafeSendersDisable", @@ -2263,9 +2229,7 @@ "impactColour": "warning", "addedDate": "2023-10-26", "powershellEquivalent": "Set-MailboxJunkEmailConfiguration", - "recommendedBy": [ - "CIPP" - ] + "recommendedBy": ["CIPP"] }, { "name": "standards.DelegateSentItems", @@ -2301,9 +2265,7 @@ "impactColour": "warning", "addedDate": "2022-05-25", "powershellEquivalent": "Set-Mailbox", - "recommendedBy": [ - "CIPP" - ] + "recommendedBy": ["CIPP"] }, { "name": "standards.UserSubmissions", @@ -2346,11 +2308,7 @@ { "name": "standards.DisableSharedMailbox", "cat": "Exchange Standards", - "tag": [ - "CIS M365 5.0 (1.2.2)", - "CISA (MS.AAD.10.1v1)", - "NIST CSF 2.0 (PR.AA-01)" - ], + "tag": ["CIS M365 5.0 (1.2.2)", "CISA (MS.AAD.10.1v1)", "NIST CSF 2.0 (PR.AA-01)"], "helpText": "Blocks login for all accounts that are marked as a shared mailbox. This is Microsoft best practice to prevent direct logons to shared mailboxes.", "docsDescription": "Shared mailboxes can be directly logged into if the password is reset, this presents a security risk as do all shared login credentials. Microsoft's recommendation is to disable the user account for shared mailboxes. It would be a good idea to review the sign-in reports to establish potential impact.", "executiveText": "Prevents direct login to shared mailbox accounts (like info@company.com), ensuring they can only be accessed through authorized users' accounts. This security measure eliminates the risk of shared passwords and unauthorized access while maintaining proper access control and audit trails.", @@ -2360,17 +2318,12 @@ "impactColour": "warning", "addedDate": "2021-11-16", "powershellEquivalent": "Get-Mailbox & Update-MgUser", - "recommendedBy": [ - "CIS", - "CIPP" - ] + "recommendedBy": ["CIS", "CIPP"] }, { "name": "standards.DisableResourceMailbox", "cat": "Exchange Standards", - "tag": [ - "NIST CSF 2.0 (PR.AA-01)" - ], + "tag": ["NIST CSF 2.0 (PR.AA-01)"], "helpText": "Blocks login for all accounts that are marked as a resource mailbox and does not have a license assigned. Accounts that are synced from on-premises AD are excluded, as account state is managed in the on-premises AD.", "docsDescription": "Resource mailboxes can be directly logged into if the password is reset, this presents a security risk as do all shared login credentials. Microsoft's recommendation is to disable the user account for resource mailboxes. 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 resource mailbox accounts (like conference rooms or equipment), ensuring they can only be managed through proper administrative channels. This security measure eliminates potential unauthorized access to resource scheduling systems while maintaining proper booking functionality.", @@ -2380,10 +2333,7 @@ "impactColour": "warning", "addedDate": "2025-06-01", "powershellEquivalent": "Get-Mailbox & Update-MgUser", - "recommendedBy": [ - "Microsoft", - "CIPP" - ] + "recommendedBy": ["Microsoft", "CIPP"] }, { "name": "standards.EXODisableAutoForwarding", @@ -2404,17 +2354,12 @@ "impactColour": "danger", "addedDate": "2024-07-26", "powershellEquivalent": "Set-HostedOutboundSpamFilterPolicy -AutoForwardingMode 'Off'", - "recommendedBy": [ - "CIS", - "CIPP" - ] + "recommendedBy": ["CIS", "CIPP"] }, { "name": "standards.RetentionPolicyTag", "cat": "Exchange Standards", - "tag": [ - "CIS M365 5.0 (6.4.1)" - ], + "tag": ["CIS M365 5.0 (6.4.1)"], "helpText": "Creates a CIPP - Deleted Items retention policy tag that permanently deletes items in the Deleted Items folder after X days.", "docsDescription": "Creates a CIPP - Deleted Items retention policy tag that permanently deletes items in the Deleted Items folder after X days.", "executiveText": "Automatically and permanently removes deleted emails after a specified number of days, helping manage storage costs and ensuring compliance with data retention policies. This prevents accumulation of unnecessary deleted items while maintaining a reasonable recovery window for accidentally deleted emails.", @@ -2527,7 +2472,24 @@ "CIS M365 5.0 (2.1.1)", "mdo_safelinksforemail", "mdo_safelinksforOfficeApps", - "NIST CSF 2.0 (DE.CM-09)" + "NIST CSF 2.0 (DE.CM-09)", + "ORCA105", + "ORCA106", + "ORCA107", + "ORCA112", + "ORCA113", + "ORCA114", + "ORCA116", + "ORCA119", + "ORCA156", + "ORCA179", + "ORCA226", + "ORCA236", + "ORCA237", + "ORCA238", + "CISAMSEXO151", + "CISAMSEXO152", + "CISAMSEXO153" ], "helpText": "This creates a Safe Links policy that automatically scans, tracks, and and enables safe links for Email, Office, and Teams for both external and internal senders", "addedComponent": [ @@ -2567,9 +2529,7 @@ "impactColour": "info", "addedDate": "2024-03-25", "powershellEquivalent": "Set-SafeLinksPolicy or New-SafeLinksPolicy", - "recommendedBy": [ - "CIS" - ] + "recommendedBy": ["CIS"] }, { "name": "standards.AntiPhishPolicy", @@ -2583,7 +2543,30 @@ "mdo_antiphishingpolicies", "mdo_phishthresholdlevel", "CIS M365 5.0 (2.1.7)", - "NIST CSF 2.0 (DE.CM-09)" + "NIST CSF 2.0 (DE.CM-09)", + "ORCA104", + "ORCA115", + "ORCA180", + "ORCA220", + "ORCA221", + "ORCA222", + "ORCA223", + "ORCA228", + "ORCA229", + "ORCA230", + "ORCA233", + "ORCA234", + "ORCA235", + "ORCA239", + "ORCA242", + "ORCA243", + "ORCA244", + "ZTNA21784", + "ZTNA21817", + "ZTNA21819", + "CISAMSEXO111", + "CISAMSEXO112", + "CISAMSEXO113" ], "helpText": "This creates a Anti-Phishing policy that automatically enables Mailbox Intelligence and spoofing, optional switches for Mail tips.", "addedComponent": [ @@ -2598,7 +2581,11 @@ "type": "number", "label": "Phishing email threshold. (Default 1)", "name": "standards.AntiPhishPolicy.PhishThresholdLevel", - "defaultValue": 1 + "defaultValue": 1, + "validators": { + "min": { "value": 1, "message": "Minimum value is 1" }, + "max": { "value": 4, "message": "Maximum value is 4" } + } }, { "type": "switch", @@ -2790,9 +2777,7 @@ "impactColour": "info", "addedDate": "2024-03-25", "powershellEquivalent": "Set-AntiPhishPolicy or New-AntiPhishPolicy", - "recommendedBy": [ - "CIS" - ] + "recommendedBy": ["CIS"] }, { "name": "standards.SafeAttachmentPolicy", @@ -2802,7 +2787,9 @@ "mdo_safedocuments", "mdo_commonattachmentsfilter", "mdo_safeattachmentpolicy", - "NIST CSF 2.0 (DE.CM-09)" + "NIST CSF 2.0 (DE.CM-09)", + "ORCA158", + "ORCA227" ], "helpText": "This creates a Safe Attachment policy", "addedComponent": [ @@ -2876,17 +2863,12 @@ "impactColour": "info", "addedDate": "2024-03-25", "powershellEquivalent": "Set-SafeAttachmentPolicy or New-SafeAttachmentPolicy", - "recommendedBy": [ - "CIS" - ] + "recommendedBy": ["CIS"] }, { "name": "standards.AtpPolicyForO365", "cat": "Defender Standards", - "tag": [ - "CIS M365 5.0 (2.1.5)", - "NIST CSF 2.0 (DE.CM-09)" - ], + "tag": ["CIS M365 5.0 (2.1.5)", "NIST CSF 2.0 (DE.CM-09)"], "helpText": "This creates a Atp policy that enables Defender for Office 365 for SharePoint, OneDrive and Microsoft Teams.", "addedComponent": [ { @@ -2902,9 +2884,7 @@ "impactColour": "info", "addedDate": "2024-03-25", "powershellEquivalent": "Set-AtpPolicyForO365", - "recommendedBy": [ - "CIS" - ] + "recommendedBy": ["CIS"] }, { "name": "standards.PhishingSimulations", @@ -2960,7 +2940,16 @@ "mdo_zapspam", "mdo_zapphish", "mdo_zapmalware", - "NIST CSF 2.0 (DE.CM-09)" + "NIST CSF 2.0 (DE.CM-09)", + "ORCA121", + "ORCA124", + "ORCA232", + "ZTNA21817", + "ZTNA21819", + "CISAMSEXO95", + "CISAMSEXO101", + "CISAMSEXO102", + "CISAMSEXO103" ], "helpText": "This creates a Malware filter policy that enables the default File filter and Zero-hour auto purge for malware.", "addedComponent": [ @@ -3054,9 +3043,7 @@ "impactColour": "info", "addedDate": "2024-03-25", "powershellEquivalent": "Set-MalwareFilterPolicy or New-MalwareFilterPolicy", - "recommendedBy": [ - "CIS" - ] + "recommendedBy": ["CIS"] }, { "name": "standards.PhishSimSpoofIntelligence", @@ -3090,7 +3077,25 @@ { "name": "standards.SpamFilterPolicy", "cat": "Defender Standards", - "tag": [], + "tag": [ + "ORCA100", + "ORCA101", + "ORCA102", + "ORCA103", + "ORCA104", + "ORCA123", + "ORCA139", + "ORCA140", + "ORCA141", + "ORCA142", + "ORCA143", + "ORCA224", + "ORCA231", + "ORCA241", + "CISAMSEXO141", + "CISAMSEXO142", + "CISAMSEXO143" + ], "helpText": "This standard creates a Spam filter policy similar to the default strict policy.", "docsDescription": "This standard creates a Spam filter policy similar to the default strict policy, the following settings are configured to on by default: IncreaseScoreWithNumericIps, IncreaseScoreWithRedirectToOtherPort, MarkAsSpamEmptyMessages, MarkAsSpamJavaScriptInHtml, MarkAsSpamSpfRecordHardFail, MarkAsSpamFromAddressAuthFail, MarkAsSpamNdrBackscatter, MarkAsSpamBulkMail, InlineSafetyTipsEnabled, PhishZapEnabled, SpamZapEnabled", "addedComponent": [ @@ -3105,7 +3110,11 @@ "type": "number", "label": "Bulk email threshold (Default 7)", "name": "standards.SpamFilterPolicy.BulkThreshold", - "defaultValue": 7 + "defaultValue": 7, + "validators": { + "min": { "value": 1, "message": "Minimum value is 1" }, + "max": { "value": 9, "message": "Maximum value is 9" } + } }, { "type": "autoComplete", @@ -3478,6 +3487,34 @@ "powershellEquivalent": "Set-QuarantinePolicy or New-QuarantinePolicy", "recommendedBy": [] }, + { + "name": "standards.IntuneWindowsDiagnostic", + "cat": "Intune Standards", + "tag": [], + "helpText": "**Some features require Windows E3 or equivalent licenses** Configures Windows diagnostic data settings for Intune. Enables features like Windows update reports, device readiness reports, and driver update reports. More information can be found in [Microsoft's documentation.](https://go.microsoft.com/fwlink/?linkid=2204384)", + "docsDescription": "Enables Windows diagnostic data in processor configuration for your Intune tenant. This setting is required for several Intune features including Windows feature update device readiness reports, compatibility risk reports, driver update reports, and update policy alerts. When enabled, your organization becomes the controller of Windows diagnostic data collected from managed devices, allowing Intune to use this data for reporting and update management features. More information can be found in [Microsoft's documentation.](https://go.microsoft.com/fwlink/?linkid=2204384)", + "executiveText": "Enables access to Windows Update reporting and compatibility analysis features in Intune by allowing the use of Windows diagnostic data. This unlocks important capabilities like device readiness reports for feature updates, driver update reports, and proactive alerts for update failures, helping IT teams plan and monitor Windows updates more effectively across the organization.", + "addedComponent": [ + { + "type": "switch", + "name": "standards.IntuneWindowsDiagnostic.areDataProcessorServiceForWindowsFeaturesEnabled", + "label": "Enable Windows data", + "defaultValue": false + }, + { + "type": "switch", + "name": "standards.IntuneWindowsDiagnostic.hasValidWindowsLicense", + "label": "Confirm ownership of the required Windows E3 or equivalent licenses (Enables Windows update app and driver compatibility reports)", + "defaultValue": false + } + ], + "label": "Set Intune Windows diagnostic data settings", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2026-01-27", + "powershellEquivalent": "Graph API", + "recommendedBy": [] + }, { "name": "standards.intuneDeviceRetirementDays", "cat": "Intune Standards", @@ -3496,9 +3533,7 @@ "impactColour": "info", "addedDate": "2023-05-19", "powershellEquivalent": "Graph API", - "recommendedBy": [ - "CIPP" - ] + "recommendedBy": ["CIPP"] }, { "name": "standards.intuneBrandingProfile", @@ -3602,7 +3637,12 @@ { "type": "number", "name": "standards.IntuneComplianceSettings.deviceComplianceCheckinThresholdDays", - "label": "Compliance status validity period (days)" + "label": "Compliance status validity period (days)", + "defaultValue": 130, + "validators": { + "min": { "value": 1, "message": "Minimum value is 1" }, + "max": { "value": 120, "message": "Maximum value is 120" } + } } ], "label": "Set Intune Compliance Settings", @@ -3656,9 +3696,7 @@ { "name": "standards.DefaultPlatformRestrictions", "cat": "Intune Standards", - "tag": [ - "CISA (MS.AAD.19.1v1)" - ], + "tag": ["CISA (MS.AAD.19.1v1)"], "helpText": "Sets the default platform restrictions for enrolling devices into Intune. Note: Do not block personally owned if platform is blocked.", "executiveText": "Controls which types of devices (iOS, Android, Windows, macOS) and ownership models (corporate vs. personal) can be enrolled in the company's device management system. This helps maintain security standards while supporting necessary business device types and usage scenarios.", "addedComponent": [ @@ -3730,6 +3768,27 @@ "powershellEquivalent": "Graph API", "recommendedBy": [] }, + { + "name": "standards.MDMEnrollmentDuringRegistration", + "cat": "Intune Standards", + "tag": [], + "helpText": "Controls the \"Allow my organization to manage my device\" prompt when adding a work or school account on Windows. This setting determines whether automatic MDM enrollment occurs during account registration.", + "docsDescription": "Controls whether Windows shows the \"Allow my organization to manage my device\" prompt when users add a work or school account. When set to disabled, this setting prevents automatic MDM enrollment during the account registration flow, separating account registration from device enrollment. This is useful for environments where you want to allow users to add work accounts without triggering MDM enrollment.", + "executiveText": "Controls automatic device management enrollment during work account setup. When disabled, users can add work accounts to their Windows devices without the prompt asking to allow organizational device management, preventing unintended MDM enrollments on personal or BYOD devices.", + "addedComponent": [ + { + "type": "switch", + "name": "standards.MDMEnrollmentDuringRegistration.disableEnrollment", + "label": "Disable MDM enrollment during registration" + } + ], + "label": "Configure MDM enrollment when adding work or school account", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2025-12-15", + "powershellEquivalent": "Graph API", + "recommendedBy": [] + }, { "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration", "cat": "Intune Standards", @@ -3767,13 +3826,21 @@ "type": "number", "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.pinMinimumLength", "label": "Minimum PIN length (4-127)", - "default": 4 + "default": 4, + "validators": { + "min": { "value": 4, "message": "Minimum value is 4" }, + "max": { "value": 127, "message": "Maximum value is 127" } + } }, { "type": "number", "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.pinMaximumLength", "label": "Maximum PIN length (4-127)", - "default": 127 + "default": 127, + "validators": { + "min": { "value": 4, "message": "Minimum value is 4" }, + "max": { "value": 127, "message": "Maximum value is 127" } + } }, { "type": "autoComplete", @@ -3890,9 +3957,7 @@ { "name": "standards.intuneDeviceReg", "cat": "Intune Standards", - "tag": [ - "CISA (MS.AAD.17.1v1)" - ], + "tag": ["CISA (MS.AAD.17.1v1)", "ZTNA21801", "ZTNA21802"], "helpText": "Sets the maximum number of devices that can be registered by a user. A value of 0 disables device registration by users", "executiveText": "Limits how many devices each employee can register for corporate access, preventing excessive device proliferation while accommodating legitimate business needs. This helps maintain security oversight and prevents potential abuse of device registration privileges.", "addedComponent": [ @@ -3913,7 +3978,7 @@ { "name": "standards.intuneRequireMFA", "cat": "Intune Standards", - "tag": [], + "tag": ["ZTNA21782", "ZTNA21796", "ZTNA21872"], "helpText": "Requires MFA for all users to register devices with Intune. This is useful when not using Conditional Access.", "executiveText": "Requires employees to use multi-factor authentication when registering devices for corporate access, adding an extra security layer to prevent unauthorized device enrollment. This helps ensure only legitimate users can connect their devices to company systems.", "label": "Require Multi-factor Authentication to register or join devices with Microsoft Entra", @@ -4012,7 +4077,11 @@ "type": "number", "name": "standards.SPFileRequests.expirationDays", "label": "Link Expiration 1-730 Days (Optional)", - "required": false + "required": false, + "validators": { + "min": { "value": 1, "message": "Minimum value is 1" }, + "max": { "value": 730, "message": "Maximum value is 730" } + } } ], "label": "Set SharePoint and OneDrive File Requests", @@ -4020,9 +4089,7 @@ "impactColour": "warning", "addedDate": "2025-07-30", "powershellEquivalent": "Set-SPOTenant -CoreRequestFilesLinkEnabled $true -OneDriveRequestFilesLinkEnabled $true -CoreRequestFilesLinkExpirationInDays 30 -OneDriveRequestFilesLinkExpirationInDays 30", - "recommendedBy": [ - "CIPP" - ] + "recommendedBy": ["CIPP"] }, { "name": "standards.TenantDefaultTimezone", @@ -4047,9 +4114,7 @@ { "name": "standards.SPAzureB2B", "cat": "SharePoint Standards", - "tag": [ - "CIS M365 5.0 (7.2.2)" - ], + "tag": ["CIS M365 5.0 (7.2.2)"], "helpText": "Ensure SharePoint and OneDrive integration with Azure AD B2B is enabled", "executiveText": "Enables secure collaboration with external partners through SharePoint and OneDrive by integrating with Azure B2B guest access. This allows controlled sharing with external organizations while maintaining security oversight and proper access management.", "addedComponent": [], @@ -4058,18 +4123,12 @@ "impactColour": "info", "addedDate": "2024-07-09", "powershellEquivalent": "Set-SPOTenant -EnableAzureADB2BIntegration $true", - "recommendedBy": [ - "CIS" - ] + "recommendedBy": ["CIS"] }, { "name": "standards.SPDisallowInfectedFiles", "cat": "SharePoint Standards", - "tag": [ - "CIS M365 5.0 (7.3.1)", - "CISA (MS.SPO.3.1v1)", - "NIST CSF 2.0 (DE.CM-09)" - ], + "tag": ["CIS M365 5.0 (7.3.1)", "CISA (MS.SPO.3.1v1)", "NIST CSF 2.0 (DE.CM-09)", "ZTNA21817"], "helpText": "Ensure Office 365 SharePoint infected files are disallowed for download", "executiveText": "Prevents employees from downloading files that have been identified as containing malware or viruses from SharePoint and OneDrive. This security measure protects against malware distribution through file sharing while maintaining access to clean, safe documents.", "addedComponent": [], @@ -4078,10 +4137,7 @@ "impactColour": "info", "addedDate": "2024-07-09", "powershellEquivalent": "Set-SPOTenant -DisallowInfectedFileDownload $true", - "recommendedBy": [ - "CIS", - "CIPP" - ] + "recommendedBy": ["CIS", "CIPP"] }, { "name": "standards.SPDisableLegacyWorkflows", @@ -4109,25 +4165,24 @@ "impactColour": "warning", "addedDate": "2024-07-09", "powershellEquivalent": "Set-SPOTenant -DefaultSharingLinkType Direct", - "recommendedBy": [ - "CIS", - "CIPP" - ] + "recommendedBy": ["CIS", "CIPP"] }, { "name": "standards.SPExternalUserExpiration", "cat": "SharePoint Standards", - "tag": [ - "CIS M365 5.0 (7.2.9)", - "CISA (MS.SPO.1.5v1)" - ], + "tag": ["CIS M365 5.0 (7.2.9)", "CISA (MS.SPO.1.5v1)", "ZTNA21803", "ZTNA21804", "ZTNA21858"], "helpText": "Ensure guest access to a site or OneDrive will expire automatically", "executiveText": "Automatically expires external user access to SharePoint sites and OneDrive after a specified period, reducing security risks from forgotten or unnecessary guest accounts. This ensures external access is regularly reviewed and maintained only when actively needed.", "addedComponent": [ { "type": "number", "name": "standards.SPExternalUserExpiration.Days", - "label": "Days until expiration (Default 60)" + "label": "Days until expiration (Default 60)", + "defaultValue": 60, + "validators": { + "min": { "value": 1, "message": "Minimum value is 1" }, + "max": { "value": 730, "message": "Maximum value is 730" } + } } ], "label": "Set guest access to expire automatically", @@ -4135,24 +4190,24 @@ "impactColour": "warning", "addedDate": "2024-07-09", "powershellEquivalent": "Set-SPOTenant -ExternalUserExpireInDays 30 -ExternalUserExpirationRequired $True", - "recommendedBy": [ - "CIS" - ] + "recommendedBy": ["CIS"] }, { "name": "standards.SPEmailAttestation", "cat": "SharePoint Standards", - "tag": [ - "CIS M365 5.0 (7.2.10)", - "CISA (MS.SPO.1.6v1)" - ], + "tag": ["CIS M365 5.0 (7.2.10)", "CISA (MS.SPO.1.6v1)", "ZTNA21803", "ZTNA21804"], "helpText": "Ensure re-authentication with verification code is restricted", "executiveText": "Requires external users to periodically re-verify their identity through email verification codes when accessing SharePoint resources, adding an extra security layer for external collaboration. This helps ensure continued legitimacy of external access over time.", "addedComponent": [ { "type": "number", "name": "standards.SPEmailAttestation.Days", - "label": "Require re-authentication every X Days (Default 15)" + "label": "Require re-authentication every X Days (Default 15)", + "defaultValue": 15, + "validators": { + "min": { "value": 1, "message": "Minimum value is 1" }, + "max": { "value": 365, "message": "Maximum value is 365" } + } } ], "label": "Require re-authentication with verification code", @@ -4160,10 +4215,7 @@ "impactColour": "warning", "addedDate": "2024-07-09", "powershellEquivalent": "Set-SPOTenant -EmailAttestationRequired $true -EmailAttestationReAuthDays 15", - "recommendedBy": [ - "CIS", - "CIPP" - ] + "recommendedBy": ["CIS", "CIPP"] }, { "name": "standards.DefaultSharingLink", @@ -4171,7 +4223,9 @@ "tag": [ "CIS M365 5.0 (7.2.7)", "CIS M365 5.0 (7.2.11)", - "CISA (MS.SPO.1.4v1)" + "CISA (MS.SPO.1.4v1)", + "ZTNA21803", + "ZTNA21804" ], "helpText": "Configure the SharePoint default sharing link type and permission. This setting controls both the type of sharing link created by default and the permission level assigned to those links.", "docsDescription": "Sets the default sharing link type (Direct or Internal) and permission (View) in SharePoint and OneDrive. Direct sharing means links only work for specific people, while Internal sharing means links work for anyone in the organization. Setting the view permission as the default ensures that users must deliberately select the edit permission when sharing a link, reducing the risk of unintentionally granting edit privileges.", @@ -4201,10 +4255,7 @@ "impactColour": "info", "addedDate": "2025-06-13", "powershellEquivalent": "Set-SPOTenant -DefaultSharingLinkType [Direct|Internal] -DefaultLinkPermission View", - "recommendedBy": [ - "CIS", - "CIPP" - ] + "recommendedBy": ["CIS", "CIPP"] }, { "name": "standards.DisableAddShortcutsToOneDrive", @@ -4278,7 +4329,9 @@ "CIS M365 5.0 (7.2.1)", "spo_legacy_auth", "CISA (MS.AAD.3.1v1)", - "NIST CSF 2.0 (PR.IR-01)" + "NIST CSF 2.0 (PR.IR-01)", + "ZTNA21776", + "ZTNA21797" ], "helpText": "Disables the ability to authenticate with SharePoint using legacy authentication methods. Any applications that use legacy authentication will need to be updated to use modern authentication.", "docsDescription": "Disables the ability for users and applications to access SharePoint via legacy basic authentication. This will likely not have any user impact, but will block systems/applications depending on basic auth or the SharePointOnlineCredentials class.", @@ -4289,10 +4342,7 @@ "impactColour": "warning", "addedDate": "2024-02-05", "powershellEquivalent": "Set-SPOTenant -LegacyAuthProtocolsEnabled $false", - "recommendedBy": [ - "CIS", - "CIPP" - ] + "recommendedBy": ["CIS", "CIPP"] }, { "name": "standards.sharingCapability", @@ -4300,7 +4350,9 @@ "tag": [ "CIS M365 5.0 (7.2.3)", "CISA (MS.AAD.14.1v1)", - "CISA (MS.SPO.1.1v1)" + "CISA (MS.SPO.1.1v1)", + "ZTNA21803", + "ZTNA21804" ], "helpText": "Sets the default sharing level for OneDrive and SharePoint. This is a tenant wide setting and overrules any settings set on the site level", "executiveText": "Defines the organization's default policy for sharing files and folders in SharePoint and OneDrive, balancing collaboration needs with security requirements. This fundamental setting determines whether employees can share with external users, anonymous links, or only internal colleagues.", @@ -4335,10 +4387,7 @@ "impactColour": "danger", "addedDate": "2022-06-15", "powershellEquivalent": "Update-MgBetaAdminSharePointSetting", - "recommendedBy": [ - "CIS", - "CIPP" - ] + "recommendedBy": ["CIS", "CIPP"] }, { "name": "standards.DisableReshare", @@ -4346,7 +4395,9 @@ "tag": [ "CIS M365 5.0 (7.2.5)", "CISA (MS.AAD.14.2v1)", - "CISA (MS.SPO.1.2v1)" + "CISA (MS.SPO.1.2v1)", + "ZTNA21803", + "ZTNA21804" ], "helpText": "Disables the ability for external users to share files they don't own. Sharing links can only be made for People with existing access", "docsDescription": "Disables the ability for external users to share files they don't own. Sharing links can only be made for People with existing access. This is a tenant wide setting and overrules any settings set on the site level", @@ -4357,10 +4408,7 @@ "impactColour": "danger", "addedDate": "2022-06-15", "powershellEquivalent": "Update-MgBetaAdminSharePointSetting", - "recommendedBy": [ - "CIS", - "CIPP" - ] + "recommendedBy": ["CIS", "CIPP"] }, { "name": "standards.DisableUserSiteCreate", @@ -4414,11 +4462,7 @@ { "name": "standards.unmanagedSync", "cat": "SharePoint Standards", - "tag": [ - "CIS M365 5.0 (7.2.3)", - "CISA (MS.SPO.2.1v1)", - "NIST CSF 2.0 (PR.AA-05)" - ], + "tag": ["CIS M365 5.0 (7.2.3)", "CISA (MS.SPO.2.1v1)", "NIST CSF 2.0 (PR.AA-05)", "ZTNA24824"], "helpText": "Entra P1 required. Block or limit access to SharePoint and OneDrive content from unmanaged devices (those not hybrid AD joined or compliant in Intune). These controls rely on Microsoft Entra Conditional Access policies and can take up to 24 hours to take effect.", "docsDescription": "Entra P1 required. Block or limit access to SharePoint and OneDrive content from unmanaged devices (those not hybrid AD joined or compliant in Intune). These controls rely on Microsoft Entra Conditional Access policies and can take up to 24 hours to take effect. 0 = Allow Access, 1 = Allow limited, web-only access, 2 = Block access. All information about this can be found in Microsofts documentation [here.](https://learn.microsoft.com/en-us/sharepoint/control-access-from-unmanaged-devices)", "executiveText": "Restricts access to company files from personal or unmanaged devices, ensuring corporate data can only be accessed from properly secured and monitored devices. This critical security control prevents data leaks while allowing controlled access through web browsers when necessary.", @@ -4447,9 +4491,7 @@ "impactColour": "danger", "addedDate": "2025-06-13", "powershellEquivalent": "Set-SPOTenant -ConditionalAccessPolicy AllowFullAccess | AllowLimitedAccess | BlockAccess", - "recommendedBy": [ - "CIS" - ] + "recommendedBy": ["CIS"] }, { "name": "standards.sharingDomainRestriction", @@ -4457,7 +4499,9 @@ "tag": [ "CIS M365 5.0 (7.2.6)", "CISA (MS.AAD.14.3v1)", - "CISA (MS.SPO.1.3v1)" + "CISA (MS.SPO.1.3v1)", + "ZTNA21803", + "ZTNA21804" ], "helpText": "Restricts sharing to only users with the specified domain. This is useful for organizations that only want to share with their own domain.", "executiveText": "Controls which external domains employees can share files with, enabling secure collaboration with trusted partners while blocking sharing with unauthorized organizations. This targeted approach maintains necessary business relationships while preventing data exposure to unknown entities.", @@ -4507,7 +4551,7 @@ "CIS M365 5.0 (8.5.5)", "CIS M365 5.0 (8.5.6)" ], - "helpText": "Defines the CIS recommended global meeting policy for Teams. This includes AllowAnonymousUsersToJoinMeeting, AllowAnonymousUsersToStartMeeting, AutoAdmittedUsers, AllowPSTNUsersToBypassLobby, MeetingChatEnabledType, DesignatedPresenterRoleMode, AllowExternalParticipantGiveRequestControl", + "helpText": "Defines the CIS recommended global meeting policy for Teams. This includes AllowAnonymousUsersToJoinMeeting, AllowAnonymousUsersToStartMeeting, AutoAdmittedUsers, AllowPSTNUsersToBypassLobby, MeetingChatEnabledType, DesignatedPresenterRoleMode, AllowExternalParticipantGiveRequestControl, AllowParticipantGiveRequestControl", "executiveText": "Establishes security-focused default settings for Teams meetings, controlling who can join meetings, present content, and participate in chats. These policies balance collaboration needs with security requirements, ensuring meetings remain productive while protecting against unauthorized access and disruption.", "addedComponent": [ { @@ -4586,6 +4630,11 @@ } ] }, + { + "type": "switch", + "name": "standards.TeamsGlobalMeetingPolicy.AllowParticipantGiveRequestControl", + "label": "Participants can give or request control" + }, { "type": "switch", "name": "standards.TeamsGlobalMeetingPolicy.AllowExternalParticipantGiveRequestControl", @@ -4596,10 +4645,8 @@ "impact": "Low Impact", "impactColour": "info", "addedDate": "2024-11-12", - "powershellEquivalent": "Set-CsTeamsMeetingPolicy -AllowAnonymousUsersToJoinMeeting $false -AllowAnonymousUsersToStartMeeting $false -AutoAdmittedUsers $AutoAdmittedUsers -AllowPSTNUsersToBypassLobby $false -MeetingChatEnabledType EnabledExceptAnonymous -DesignatedPresenterRoleMode $DesignatedPresenterRoleMode -AllowExternalParticipantGiveRequestControl $false", - "recommendedBy": [ - "CIS" - ] + "powershellEquivalent": "Set-CsTeamsMeetingPolicy -AllowAnonymousUsersToJoinMeeting $false -AllowAnonymousUsersToStartMeeting $false -AutoAdmittedUsers $AutoAdmittedUsers -AllowPSTNUsersToBypassLobby $false -MeetingChatEnabledType EnabledExceptAnonymous -DesignatedPresenterRoleMode $DesignatedPresenterRoleMode -AllowExternalParticipantGiveRequestControl $false -AllowParticipantGiveRequestControl $false", + "recommendedBy": ["CIS"] }, { "name": "standards.TeamsChatProtection", @@ -4627,9 +4674,7 @@ "impactColour": "info", "addedDate": "2025-10-02", "powershellEquivalent": "Set-CsTeamsMessagingConfiguration -FileTypeCheck 'Enabled' -UrlReputationCheck 'Enabled' -ReportIncorrectSecurityDetections 'Enabled'", - "recommendedBy": [ - "CIPP" - ] + "recommendedBy": ["CIPP"] }, { "name": "standards.TeamsExternalChatWithAnyone", @@ -4661,9 +4706,7 @@ "impactColour": "info", "addedDate": "2025-11-03", "powershellEquivalent": "Set-CsTeamsMessagingPolicy -Identity Global -UseB2BInvitesToAddExternalUsers $false/$true", - "recommendedBy": [ - "CIPP" - ] + "recommendedBy": ["CIPP"] }, { "name": "standards.TeamsEmailIntegration", @@ -4683,12 +4726,8 @@ "impactColour": "info", "addedDate": "2024-07-30", "powershellEquivalent": "Set-CsTeamsClientConfiguration -AllowEmailIntoChannel $false", - "recommendedBy": [ - "CIS" - ], - "tag": [ - "CIS M365 5.0 (8.1.2)" - ] + "recommendedBy": ["CIS"], + "tag": ["CIS M365 5.0 (8.1.2)"] }, { "name": "standards.TeamsGuestAccess", @@ -4742,16 +4781,12 @@ "impactColour": "info", "addedDate": "2025-06-14", "powershellEquivalent": "Set-CsTeamsMeetingPolicy -CaptchaVerificationForMeetingJoin", - "recommendedBy": [ - "CIPP" - ] + "recommendedBy": ["CIPP"] }, { "name": "standards.TeamsExternalFileSharing", "cat": "Teams Standards", - "tag": [ - "CIS M365 5.0 (8.4.1)" - ], + "tag": ["CIS M365 5.0 (8.4.1)"], "helpText": "Ensure external file sharing in Teams is enabled for only approved cloud storage services.", "executiveText": "Controls which external cloud storage services (like Google Drive, Dropbox, Box) employees can access through Teams, ensuring file sharing occurs only through approved and secure platforms. This helps maintain data governance while supporting necessary business integrations.", "addedComponent": [ @@ -4786,9 +4821,7 @@ "impactColour": "info", "addedDate": "2024-07-28", "powershellEquivalent": "Set-CsTeamsClientConfiguration -AllowGoogleDrive $false -AllowShareFile $false -AllowBox $false -AllowDropBox $false -AllowEgnyte $false", - "recommendedBy": [ - "CIS" - ] + "recommendedBy": ["CIS"] }, { "name": "standards.TeamsEnrollUser", @@ -4915,7 +4948,12 @@ "type": "number", "name": "standards.TeamsMeetingRecordingExpiration.ExpirationDays", "label": "Recording Expiration Days (e.g., 365)", - "required": true + "required": true, + "defaultValue": 120, + "validators": { + "min": { "value": -1, "message": "Minimum value is -1" }, + "max": { "value": 99999, "message": "Maximum value is 99999" } + } } ], "label": "Set Teams Meeting Recording Expiration", @@ -5028,7 +5066,11 @@ "type": "number", "name": "standards.AutopilotStatusPage.TimeOutInMinutes", "label": "Timeout in minutes", - "defaultValue": 60 + "defaultValue": 60, + "validators": { + "min": { "value": 1, "message": "Minimum value is 1" }, + "max": { "value": 1440, "message": "Maximum value is 1440" } + } }, { "type": "textField", @@ -5233,10 +5275,7 @@ "queryKey": "ListIntuneTemplates-tag-autcomplete", "url": "/api/ListIntuneTemplates?mode=Tag", "labelField": "label", - "valueField": "value", - "addedField": { - "templates": "templates" - } + "valueField": "value" } }, { @@ -5329,6 +5368,12 @@ "valueField": "GUID", "queryKey": "ListTransportRulesTemplates" } + }, + { + "type": "switch", + "label": "Overwrite existing transport rules", + "name": "overwrite", + "defaultValue": true } ] }, @@ -5495,7 +5540,11 @@ "type": "number", "name": "standards.MailboxRecipientLimits.RecipientLimit", "label": "Recipient Limit", - "defaultValue": 500 + "defaultValue": 500, + "validators": { + "min": { "value": 1, "message": "Minimum value is 1" }, + "max": { "value": 1000, "message": "Maximum value is 1000" } + } } ], "label": "Set Mailbox Recipient Limits", @@ -5503,18 +5552,12 @@ "impactColour": "info", "addedDate": "2025-05-28", "powershellEquivalent": "Set-Mailbox -RecipientLimits", - "recommendedBy": [ - "CIPP" - ] + "recommendedBy": ["CIPP"] }, { "name": "standards.DisableExchangeOnlinePowerShell", "cat": "Exchange Standards", - "tag": [ - "CIS M365 5.0 (6.1.1)", - "Security", - "NIST CSF 2.0 (PR.AA-05)" - ], + "tag": ["CIS M365 5.0 (6.1.1)", "Security", "NIST CSF 2.0 (PR.AA-05)"], "helpText": "Disables Exchange Online PowerShell access for non-admin users by setting the RemotePowerShellEnabled property to false for each user. This helps prevent attackers from using PowerShell to run malicious commands, access file systems, registry, and distribute ransomware throughout networks. Users with admin roles are automatically excluded.", "docsDescription": "Disables Exchange Online PowerShell access for non-admin users by setting the RemotePowerShellEnabled property to false for each user. This security measure follows a least privileged access approach, preventing potential attackers from using PowerShell to execute malicious commands, access sensitive systems, or distribute malware. Users with management roles containing 'Admin' are automatically excluded to ensure administrators retain PowerShell access to perform necessary management tasks.", "executiveText": "Restricts PowerShell access to Exchange Online for regular employees while maintaining access for administrators, significantly reducing security risks from compromised accounts. This prevents attackers from using PowerShell to execute malicious commands or distribute ransomware while preserving necessary administrative capabilities.", @@ -5523,19 +5566,12 @@ "impactColour": "warning", "addedDate": "2025-06-19", "powershellEquivalent": "Set-User -Identity $user -RemotePowerShellEnabled $false", - "recommendedBy": [ - "CIS", - "CIPP" - ] + "recommendedBy": ["CIS", "CIPP"] }, { "name": "standards.OWAAttachmentRestrictions", "cat": "Exchange Standards", - "tag": [ - "CIS M365 5.0 (6.1.2)", - "Security", - "NIST CSF 2.0 (PR.AA-05)" - ], + "tag": ["CIS M365 5.0 (6.1.2)", "Security", "NIST CSF 2.0 (PR.AA-05)"], "helpText": "Restricts how users on unmanaged devices can interact with email attachments in Outlook on the web and new Outlook for Windows. Prevents downloading attachments or blocks viewing them entirely.", "docsDescription": "This standard configures the OWA mailbox policy to restrict access to email attachments on unmanaged devices. Users can be prevented from downloading attachments (but can view/edit via Office Online) or blocked from seeing attachments entirely. This helps prevent data exfiltration through email attachments on devices not managed by the organization.", "executiveText": "Restricts access to email attachments on personal or unmanaged devices while allowing full functionality on corporate-managed devices. This security measure prevents data theft through email attachments while maintaining productivity for employees using approved company devices.", @@ -5562,13 +5598,11 @@ "impactColour": "warning", "addedDate": "2025-08-22", "powershellEquivalent": "Set-OwaMailboxPolicy -Identity \"OwaMailboxPolicy-Default\" -ConditionalAccessPolicy ReadOnlyPlusAttachmentsBlocked", - "recommendedBy": [ - "Microsoft Zero Trust", - "CIPP" - ] + "recommendedBy": ["Microsoft Zero Trust", "CIPP"] }, { "name": "standards.LegacyEmailReportAddins", + "deprecated": false, "cat": "Exchange Standards", "tag": [], "helpText": "Removes legacy Report Phishing and Report Message Outlook add-ins.", @@ -5578,9 +5612,7 @@ "impactColour": "info", "addedDate": "2025-08-26", "powershellEquivalent": "None", - "recommendedBy": [ - "Microsoft" - ] + "recommendedBy": ["Microsoft"] }, { "name": "standards.DeployCheckChromeExtension", @@ -5608,13 +5640,6 @@ "label": "Enable CIPP reporting", "defaultValue": true }, - { - "type": "textField", - "name": "standards.DeployCheckChromeExtension.cippServerUrl", - "label": "CIPP Server URL", - "placeholder": "https://YOUR-CIPP-SERVER-URL", - "required": false - }, { "type": "textField", "name": "standards.DeployCheckChromeExtension.customRulesUrl", @@ -5708,16 +5733,12 @@ "impactColour": "info", "addedDate": "2025-09-18", "powershellEquivalent": "New-GraphPostRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies'", - "recommendedBy": [ - "CIPP" - ] + "recommendedBy": ["CIPP"] }, { "name": "standards.SecureScoreRemediation", "cat": "Global Standards", - "tag": [ - "lowimpact" - ], + "tag": ["lowimpact"], "helpText": "Allows bulk updating of Secure Score control profiles across tenants. Select controls and assign them to different states: Default, Ignored, Third-Party, or Reviewed.", "addedComponent": [ { @@ -5779,4 +5800,4 @@ "addedDate": "2025-11-19", "powershellEquivalent": "New-GraphPostRequest to /beta/security/secureScoreControlProfiles/{id}" } -] \ No newline at end of file +] diff --git a/src/hooks/use-guid-resolver.js b/src/hooks/use-guid-resolver.js index 1325722fc872..f51a9a2bb201 100644 --- a/src/hooks/use-guid-resolver.js +++ b/src/hooks/use-guid-resolver.js @@ -1,6 +1,6 @@ import { useState, useCallback, useRef, useEffect } from "react"; -import { ApiPostCall } from "/src/api/ApiCall"; -import { useSettings } from "/src/hooks/use-settings"; +import { ApiPostCall } from "../api/ApiCall"; +import { useSettings } from "./use-settings"; // Function to check if a string is a GUID const isGuid = (str) => { diff --git a/src/hooks/use-permissions.js b/src/hooks/use-permissions.js index 6b7973c164f8..3fa9b6185707 100644 --- a/src/hooks/use-permissions.js +++ b/src/hooks/use-permissions.js @@ -1,6 +1,6 @@ import { useCallback } from "react"; -import { ApiGetCall } from "/src/api/ApiCall"; -import { hasAccess, hasPermission, hasRole } from "/src/utils/permissions"; +import { ApiGetCall } from "../api/ApiCall"; +import { hasAccess, hasPermission, hasRole } from "../utils/permissions"; /** * Hook for checking user permissions and roles diff --git a/src/hooks/use-securescore.js b/src/hooks/use-securescore.js index f96c2bd232b7..66287c8f87bd 100644 --- a/src/hooks/use-securescore.js +++ b/src/hooks/use-securescore.js @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { ApiGetCall } from "../api/ApiCall"; import { useSettings } from "./use-settings"; -import standards from "/src/data/standards.json"; +import standards from "../data/standards.json"; export function useSecureScore({ waiting = true } = {}) { const currentTenant = useSettings().currentTenant; @@ -68,7 +68,7 @@ export function useSecureScore({ waiting = true } = {}) { complianceInformation: translation?.complianceInformation, actionUrl: remediation ? //this needs to be updated to be a direct url to apply this standard. - "/tenant/standards/list-standards" + "/tenant/standards" : translation?.actionUrl, remediation: remediation ? `1. Enable the CIPP Standard: ${remediation.label}` diff --git a/src/hooks/use-timezones.js b/src/hooks/use-timezones.js new file mode 100644 index 000000000000..1bfa4b275095 --- /dev/null +++ b/src/hooks/use-timezones.js @@ -0,0 +1,52 @@ +import { useState, useEffect } from "react"; +import { getTimeZones } from "@vvo/tzdb"; + +export const useTimezones = () => { + const [timezones, setTimezones] = useState([{ label: "UTC", value: "UTC" }]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + try { + setLoading(true); + const tzData = getTimeZones({ includeUtc: true }); + + if (!Array.isArray(tzData)) { + throw new Error("getTimeZones did not return an array"); + } + + const formattedTimezones = tzData + .filter((tz) => typeof tz?.name === "string" && tz.name.length > 0) + .map((tz) => { + const name = String(tz.name); + const current = tz?.currentTimeFormat ? String(tz.currentTimeFormat) : undefined; + const label = current ? `${name} (${current})` : name; + return { + label, + value: name, + alternativeName: tz?.alternativeName ? String(tz.alternativeName) : undefined, + }; + }) + // de-duplicate by value + .filter((item, idx, arr) => arr.findIndex((t) => t.value === item.value) === idx) + // sort by label for consistent UX + .sort((a, b) => a.label.localeCompare(b.label)); + + // Always ensure a non-empty array; prepend UTC as a safe default + const withFallback = formattedTimezones.length + ? formattedTimezones + : [{ label: "UTC", value: "UTC" }]; + setTimezones(withFallback); + setError(null); + } catch (err) { + console.error("Error loading timezones:", err); + setError(err.message); + // Fallback to UTC (already seeded), keep as-is + setTimezones((prev) => (prev?.length ? prev : [{ label: "UTC", value: "UTC" }])); + } finally { + setLoading(false); + } + }, []); + console.log("Timezones loaded:", timezones); + return { timezones, loading, error }; +}; diff --git a/src/index.js b/src/index.js index 6f78fb85ec69..141824e0c90c 100644 --- a/src/index.js +++ b/src/index.js @@ -1,5 +1,5 @@ -import { Layout as DashboardLayout } from "/src/layouts/index.js"; +import { Layout as DashboardLayout } from "./layouts/index.js"; const Page = () => { const pageTitle = "Dashboard"; diff --git a/src/layouts/HeaderedTabbedLayout.jsx b/src/layouts/HeaderedTabbedLayout.jsx index 50b1b2e1e034..1b5585a6812a 100644 --- a/src/layouts/HeaderedTabbedLayout.jsx +++ b/src/layouts/HeaderedTabbedLayout.jsx @@ -15,7 +15,7 @@ import { Tabs, Typography, } from "@mui/material"; -import { ActionsMenu } from "/src/components/actions-menu"; +import { ActionsMenu } from "../components/actions-menu"; import { useMediaQuery } from "@mui/material"; export const HeaderedTabbedLayout = (props) => { @@ -55,25 +55,12 @@ export const HeaderedTabbedLayout = (props) => { -
    - -
    { ) : ( subtitle && ( - {subtitle.map((item, index) => ( - - {item.icon} - - {item.text} - - - ))} + {subtitle.map((item, index) => + item.component ? ( + {item.component} + ) : ( + + {item.icon} + + {item.text} + + + ) + )} ) )} @@ -124,7 +115,7 @@ export const HeaderedTabbedLayout = (props) => { !mdDown && { flexGrow: 1, overflow: "auto", - height: "calc(100vh - 400px)", + height: "calc(100vh - 350px)", } } > diff --git a/src/layouts/TabbedLayout.jsx b/src/layouts/TabbedLayout.jsx index 9594443127bc..b268d6680cd5 100644 --- a/src/layouts/TabbedLayout.jsx +++ b/src/layouts/TabbedLayout.jsx @@ -1,13 +1,19 @@ import { usePathname, useRouter } from "next/navigation"; import { Box, Divider, Stack, Tab, Tabs } from "@mui/material"; +import { useSearchParams } from "next/navigation"; export const TabbedLayout = (props) => { const { tabOptions, children } = props; const router = useRouter(); const pathname = usePathname(); + const searchParams = useSearchParams(); const handleTabsChange = (event, value) => { - router.push(value); + // Preserve existing query parameters when changing tabs + const currentParams = new URLSearchParams(searchParams.toString()); + const queryString = currentParams.toString(); + const newPath = queryString ? `${value}?${queryString}` : value; + router.push(newPath); }; const currentTab = tabOptions.find((option) => option.path === pathname); @@ -16,20 +22,30 @@ export const TabbedLayout = (props) => { -
    - + + {tabOptions.map((option) => ( ))} -
    +
    + {children}
    - {children}
    ); }; diff --git a/src/layouts/config.js b/src/layouts/config.js index 115de1795d46..07ca170abc5b 100644 --- a/src/layouts/config.js +++ b/src/layouts/config.js @@ -31,7 +31,6 @@ export const nativeMenuItems = [ items: [ { title: "Administration", - path: "/identity/administration", permissions: ["Identity.User.*"], items: [ { @@ -74,6 +73,11 @@ export const nativeMenuItems = [ path: "/identity/administration/jit-admin", permissions: ["Identity.Role.*"], }, + { + title: "JIT Admin Templates", + path: "/identity/administration/jit-admin-templates", + permissions: ["Identity.Role.*"], + }, { title: "Offboarding Wizard", path: "/identity/administration/offboarding-wizard", @@ -83,7 +87,6 @@ export const nativeMenuItems = [ }, { title: "Reports", - path: "/identity/reports", permissions: [ "Identity.User.*", "Identity.Group.*", @@ -133,7 +136,6 @@ export const nativeMenuItems = [ items: [ { title: "Administration", - path: "/tenant/administration", permissions: ["Tenant.Administration.*"], items: [ { @@ -176,16 +178,20 @@ export const nativeMenuItems = [ path: "/tenant/administration/partner-relationships", permissions: ["Tenant.Relationship.*"], }, + { + title: "Domains", + path: "/tenant/administration/domains", + permissions: ["Tenant.Administration.*"], + }, ], }, { title: "GDAP Management", - path: "/tenant/gdap-management/", + path: "/tenant/gdap-management", permissions: ["Tenant.Relationship.*"], }, { title: "Standards & Drift", - path: "/tenant/standards", permissions: [ "Tenant.Standards.*", "Tenant.BestPracticeAnalyser.*", @@ -194,7 +200,7 @@ export const nativeMenuItems = [ items: [ { title: "Standards Management", - path: "/tenant/standards/list-standards", + path: "/tenant/standards/alignment", permissions: ["Tenant.Standards.*"], }, { @@ -211,7 +217,6 @@ export const nativeMenuItems = [ }, { title: "Conditional Access", - path: "/tenant/conditional", permissions: ["Tenant.ConditionalAccess.*"], items: [ { @@ -238,7 +243,6 @@ export const nativeMenuItems = [ }, { title: "Reports", - path: "/tenant/reports", permissions: ["Tenant.Administration.*", "Scheduler.Billing.*", "Tenant.Application.*"], items: [ { @@ -258,6 +262,11 @@ export const nativeMenuItems = [ }, ], }, + { + title: "Manage Tenant", + path: "/tenant/manage/edit", + permissions: ["Tenant.Administration.*"], + }, ], }, { @@ -277,7 +286,6 @@ export const nativeMenuItems = [ items: [ { title: "Incidents & Alerts", - path: "/security/incidents", permissions: ["Security.Incident.*"], items: [ { @@ -304,7 +312,6 @@ export const nativeMenuItems = [ }, { title: "Defender", - path: "/security/defender", permissions: ["Security.Alert.*"], items: [ { @@ -326,7 +333,6 @@ export const nativeMenuItems = [ }, { title: "Reports", - path: "/security/reports", permissions: ["Tenant.DeviceCompliance.*"], items: [ { @@ -338,7 +344,6 @@ export const nativeMenuItems = [ }, { title: "Safe Links", - path: "/security/safelinks", permissions: ["Security.SafeLinksPolicy.*"], items: [ { @@ -373,7 +378,6 @@ export const nativeMenuItems = [ items: [ { title: "Applications", - path: "/endpoint/applications", permissions: ["Endpoint.Application.*"], items: [ { @@ -390,7 +394,6 @@ export const nativeMenuItems = [ }, { title: "Autopilot", - path: "/endpoint/autopilot", permissions: ["Endpoint.Autopilot.*"], items: [ { @@ -417,7 +420,6 @@ export const nativeMenuItems = [ }, { title: "Device Management", - path: "/endpoint/MEM", permissions: ["Endpoint.MEM.*"], items: [ { @@ -436,7 +438,7 @@ export const nativeMenuItems = [ permissions: ["Endpoint.MEM.*"], }, { - title: "Protection Policies", + title: "App Policies", path: "/endpoint/MEM/list-appprotection-policies", permissions: ["Endpoint.MEM.*"], }, @@ -464,8 +466,7 @@ export const nativeMenuItems = [ }, { title: "Reports", - path: "/endpoint/reports", - permissions: ["Endpoint.Device.*", "Endpoint.Autopilot.*"], + permissions: ["Endpoint.Device.*", "Endpoint.Autopilot.*", "Endpoint.MEM.*"], items: [ { title: "Analytics Device Score", @@ -482,6 +483,11 @@ export const nativeMenuItems = [ path: "/endpoint/reports/autopilot-deployment", permissions: ["Endpoint.Autopilot.*"], }, + { + title: "Discovered Apps", + path: "/endpoint/reports/detected-apps", + permissions: ["Endpoint.MEM.*"], + }, ], }, ], @@ -514,7 +520,6 @@ export const nativeMenuItems = [ }, { title: "Teams", - path: "/teams-share/teams", permissions: ["Teams.Group.*"], items: [ { @@ -560,7 +565,6 @@ export const nativeMenuItems = [ items: [ { title: "Administration", - path: "/email/administration", permissions: ["Exchange.Mailbox.*"], items: [ { @@ -612,7 +616,6 @@ export const nativeMenuItems = [ }, { title: "Transport", - path: "/email/transport", permissions: ["Exchange.TransportRule.*"], items: [ { @@ -639,7 +642,6 @@ export const nativeMenuItems = [ }, { title: "Spamfilter", - path: "/email/spamfilter", permissions: ["Exchange.SpamFilter.*"], items: [ { @@ -671,7 +673,6 @@ export const nativeMenuItems = [ }, { title: "Resource Management", - path: "/email/resources/management", permissions: ["Exchange.Equipment.*"], items: [ { @@ -693,7 +694,6 @@ export const nativeMenuItems = [ }, { title: "Reports", - path: "/email/reports", permissions: [ "Exchange.Mailbox.*", "Exchange.SpamFilter.*", @@ -716,6 +716,16 @@ export const nativeMenuItems = [ path: "/email/reports/mailbox-cas-settings", permissions: ["Exchange.Mailbox.*"], }, + { + title: "Mailbox Permissions", + path: "/email/reports/mailbox-permissions", + permissions: ["Exchange.Mailbox.*"], + }, + { + title: "Calendar Permissions", + path: "/email/reports/calendar-permissions", + permissions: ["Exchange.Mailbox.*"], + }, { title: "Anti-Phishing Filters", path: "/email/reports/antiphishing-filters", @@ -759,11 +769,11 @@ export const nativeMenuItems = [ "Tenant.Application.*", "Tenant.DomainAnalyser.*", "Exchange.Mailbox.*", + "CIPP.Scheduler.*", ], items: [ { title: "Tenant Tools", - path: "/tenant/tools", permissions: ["Tenant.Administration.*"], items: [ { @@ -797,7 +807,6 @@ export const nativeMenuItems = [ }, { title: "Email Tools", - path: "/email/tools", permissions: ["Exchange.Mailbox.*"], items: [ { @@ -819,7 +828,6 @@ export const nativeMenuItems = [ }, { title: "Dark Web Tools", - path: "/tools/darkweb", permissions: ["CIPP.Core.*"], items: [ { @@ -882,7 +890,7 @@ export const nativeMenuItems = [ title: "Setup Wizard", path: "/onboardingv2", roles: ["admin", "superadmin"], - permissions: ["CIPP.Core.*"], + permissions: ["CIPP.AppSettings.*"], }, { title: "Integrations", @@ -894,7 +902,7 @@ export const nativeMenuItems = [ title: "Custom Data", path: "/cipp/custom-data/directory-extensions", roles: ["admin", "superadmin"], - permissions: ["CIPP.Core.*"], + permissions: ["CIPP.AppSettings.*"], }, { title: "Advanced", @@ -925,6 +933,12 @@ export const nativeMenuItems = [ roles: ["superadmin"], permissions: ["CIPP.SuperAdmin.*"], }, + { + title: "Diagnostics", + path: "/cipp/advanced/diagnostics", + roles: ["superadmin"], + permissions: ["CIPP.SuperAdmin.*"], + }, ], }, ], diff --git a/src/layouts/index.js b/src/layouts/index.js index 5813fc0ee70e..f7b6ccd27869 100644 --- a/src/layouts/index.js +++ b/src/layouts/index.js @@ -1,6 +1,15 @@ import { useCallback, useEffect, useState, useRef } from "react"; import { usePathname } from "next/navigation"; -import { Alert, Button, Dialog, DialogContent, DialogTitle, useMediaQuery } from "@mui/material"; +import { + Alert, + Button, + Dialog, + Divider, + DialogContent, + DialogTitle, + useMediaQuery, +} from "@mui/material"; +import { Stack } from "@mui/system"; import { styled } from "@mui/material/styles"; import { useSettings } from "../hooks/use-settings"; import { Footer } from "./footer"; @@ -14,7 +23,8 @@ import { Box, Container, Grid } from "@mui/system"; import { CippImageCard } from "../components/CippCards/CippImageCard"; import Page from "../pages/onboardingv2"; import { useDialog } from "../hooks/use-dialog"; -import { nativeMenuItems } from "/src/layouts/config"; +import { nativeMenuItems } from "./config"; +import { CippBreadcrumbNav } from "../components/CippComponents/CippBreadcrumbNav"; const SIDE_NAV_WIDTH = 270; const SIDE_NAV_PINNED_WIDTH = 50; @@ -92,6 +102,12 @@ export const Layout = (props) => { waiting: !swaStatus.isSuccess || swaStatus.data?.clientPrincipal === null, }); + const featureFlags = ApiGetCall({ + url: "/api/ListFeatureFlags", + queryKey: "featureFlags", + staleTime: 600000, // Cache for 10 minutes + }); + useEffect(() => { if (currentRole.isSuccess && !currentRole.isFetching) { const userRoles = currentRole.data?.clientPrincipal?.userRoles; @@ -101,15 +117,22 @@ export const Layout = (props) => { setHideSidebar(true); return; } + + // Get disabled pages from feature flags - only filter if we have valid data + let disabledPages = []; + if (featureFlags.isSuccess && Array.isArray(featureFlags.data)) { + disabledPages = featureFlags.data + .filter((flag) => flag.Enabled === false || flag.enabled === false) + .flatMap((flag) => flag.Pages || flag.pages || []) + .filter((page) => typeof page === "string"); + } + const filterItemsByRole = (items) => { return items .map((item) => { - // role - if (item.roles && item.roles.length > 0) { - const hasRole = item.roles.some((requiredRole) => userRoles.includes(requiredRole)); - if (!hasRole) { - return null; - } + // Check if page is disabled by feature flag + if (item.path && disabledPages.length > 0 && disabledPages.includes(item.path)) { + return null; } // Check permission with pattern matching support @@ -167,6 +190,8 @@ export const Layout = (props) => { currentRole.data?.clientPrincipal?.userRoles, currentRole.data?.permissions, currentRole.isFetching, + featureFlags.isSuccess, + featureFlags.data, ]); const handleNavPin = useCallback(() => { @@ -258,7 +283,7 @@ export const Layout = (props) => { message: alert.Alert, title: alert.title, toastError: alert, - }) + }), ); }); } @@ -314,8 +339,9 @@ export const Layout = (props) => { )} {(currentTenant === "AllTenants" || !currentTenant) && !allTenantsSupport ? ( - + + { ) : ( - <>{children} + + + + + + {children} + )}