diff --git a/.github/workflows/azure-static-web-apps-polite-forest-010314b03.yml b/.github/workflows/azure-static-web-apps-polite-forest-010314b03.yml
new file mode 100644
index 000000000000..59c26e998103
--- /dev/null
+++ b/.github/workflows/azure-static-web-apps-polite-forest-010314b03.yml
@@ -0,0 +1,43 @@
+name: Azure Static Web Apps CI/CD
+
+on:
+ push:
+ branches:
+ - main
+
+
+jobs:
+ build_and_deploy_job:
+ if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed')
+ runs-on: ubuntu-latest
+ name: Build and Deploy Job
+ steps:
+ - uses: actions/checkout@v3
+ with:
+ submodules: true
+ lfs: false
+ - name: Build And Deploy
+ id: builddeploy
+ uses: Azure/static-web-apps-deploy@v1
+ with:
+ azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_POLITE_FOREST_010314B03 }}
+ repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments)
+ action: "upload"
+ ###### Repository/Build Configurations - These values can be configured to match your app requirements. ######
+ # For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig
+ app_location: "/" # App source code path
+ api_location: "" # Api source code path - optional
+ output_location: "" # Built app content directory - optional
+ ###### End of Repository/Build Configurations ######
+
+ close_pull_request_job:
+ if: github.event_name == 'pull_request' && github.event.action == 'closed'
+ runs-on: ubuntu-latest
+ name: Close Pull Request Job
+ steps:
+ - name: Close Pull Request
+ id: closepullrequest
+ uses: Azure/static-web-apps-deploy@v1
+ with:
+ azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_POLITE_FOREST_010314B03 }}
+ action: "close"
diff --git a/.gitignore b/.gitignore
index 1f85272ad618..668afda8fa94 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,6 +24,11 @@ out
npm-debug.log*
yarn-debug.log*
yarn-error.log*
+<<<<<<< HEAD
+__*
+.next/*
+=======
# vscode debug logs
-debug.log
\ No newline at end of file
+debug.log
+>>>>>>> 81ee51ffa0576b02dc60ee5600a44cec3cde3e97
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 000000000000..38b71fec4e7f
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,10 @@
+{
+ "recommendations": [
+ "github.vscode-codeql",
+ "dbaeumer.vscode-eslint",
+ "christian-kohler.npm-intellisense",
+ "esbenp.prettier-vscode",
+ "stylelint.vscode-stylelint",
+ "editorconfig.editorconfig"
+ ]
+}
diff --git a/package.json b/package.json
index 6c2c57ba925d..f24808847b21 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,12 @@
{
"name": "cipp",
+<<<<<<< HEAD
+ "version": "6.4.1",
+ "description": "The CyberDrain Improved Partner Portal is a portal to help manage administration for Microsoft Partners.",
+=======
"version": "7.0.0",
"author": "CIPP Contributors",
+>>>>>>> 81ee51ffa0576b02dc60ee5600a44cec3cde3e97
"homepage": "https://cipp.app/",
"bugs": {
"url": "https://github.com/KelvinTegelaar/CIPP/issues"
@@ -103,8 +108,42 @@
"yup": "0.32.11"
},
"devDependencies": {
+<<<<<<< HEAD
+ "@types/react": "^18.2.39",
+ "@types/react-helmet": "^6.1.5",
+ "@vitejs/plugin-react": "^4.2.1",
+ "auto-changelog": "~2.3.0",
+ "browserslist-to-esbuild": "^1.2.0",
+ "eslint": "^8.54.0",
+ "eslint-config-prettier": "^8.3.0",
+ "eslint-import-resolver-custom-alias": "^1.3.2",
+ "eslint-plugin-import": "^2.29.0",
+ "eslint-plugin-prettier": "^4.0.0",
+ "eslint-plugin-react": "^7.33.2",
+ "eslint-plugin-react-hooks": "^4.6.0",
+ "eslint-plugin-react-refresh": "^0.4.4",
+ "husky": "^7.0.4",
+ "postcss-scss": "^4.0.3",
+ "prettier": "2.4.1",
+ "redux-immutable-state-invariant": "^2.1.0",
+ "sass": "^1.64.2",
+ "stylelint": "^14.3.0",
+ "stylelint-config-sass-guidelines": "^9.0.1",
+ "stylelint-order": "^5.0.0",
+ "vite": "^5.0.6",
+ "vite-plugin-eslint": "^1.8.1"
+ },
+ "engines": {
+ "node": "20.17.0",
+ "npm": ">=8.3.0"
+ },
+ "overrides": {
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+=======
"@svgr/webpack": "6.5.1",
"eslint": "8.32.0",
"eslint-config-next": "13.1.6"
+>>>>>>> 81ee51ffa0576b02dc60ee5600a44cec3cde3e97
}
}
diff --git a/public/img/RoB-light.svg b/public/img/RoB-light.svg
new file mode 100644
index 000000000000..0673b2a8a449
--- /dev/null
+++ b/public/img/RoB-light.svg
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/img/RoB.svg b/public/img/RoB.svg
new file mode 100644
index 000000000000..d188e1eb541b
--- /dev/null
+++ b/public/img/RoB.svg
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/version_latest.txt b/public/version_latest.txt
index 5e39348ef037..2e0b229ff633 100644
--- a/public/version_latest.txt
+++ b/public/version_latest.txt
@@ -1 +1,5 @@
-99.99.99
\ No newline at end of file
+<<<<<<< HEAD
+6.4.1
+=======
+99.99.99
+>>>>>>> 81ee51ffa0576b02dc60ee5600a44cec3cde3e97
diff --git a/src/components/layout/AppFooter.jsx b/src/components/layout/AppFooter.jsx
new file mode 100644
index 000000000000..09087af3a00e
--- /dev/null
+++ b/src/components/layout/AppFooter.jsx
@@ -0,0 +1,56 @@
+import React from 'react'
+import { CFooter, CImage, CLink } from '@coreui/react'
+import { Link } from 'react-router-dom'
+import { useSelector } from 'react-redux'
+import { useMediaPredicate } from 'react-media-hook'
+
+const AppFooter = () => {
+ const currentTheme = useSelector((state) => state.app.currentTheme)
+ const preferredTheme = useMediaPredicate('(prefers-color-scheme: dark)') ? 'impact' : 'cyberdrain'
+ const isDark =
+ currentTheme === 'impact' || (currentTheme === 'default' && preferredTheme === 'impact')
+
+ const RoB = isDark ? '/img/RoB.svg' : '/img/RoB-light.svg'
+ const huntress = isDark ? '/img/huntress_teal.png' : '/img/huntress_teal.png'
+ const rewst = isDark ? '/img/rewst_dark.png' : '/img/rewst.png'
+ const ninjaone = isDark ? '/img/ninjaone_dark.png' : '/img/ninjaone.png'
+ const augmentt = isDark ? '/img/augmentt-dark.png' : '/img/augmentt-light.png'
+
+ return (
+
+
+
+ This application is sponsored by
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ License
+
+
+ )
+}
+
+export default React.memo(AppFooter)
diff --git a/src/components/tables/CippDatatable.jsx b/src/components/tables/CippDatatable.jsx
new file mode 100644
index 000000000000..7d00d9730e82
--- /dev/null
+++ b/src/components/tables/CippDatatable.jsx
@@ -0,0 +1,68 @@
+import React from 'react'
+import { useListDatatableQuery } from 'src/store/api/datatable'
+import PropTypes from 'prop-types'
+import { CippTable } from 'src/components/tables'
+import { CippTablePropTypes } from 'src/components/tables/CippTable'
+import { CCallout } from '@coreui/react'
+
+export default function CippDatatable({ path, params, ...rest }) {
+ const [graphFilter, setGraphFilter] = React.useState(params?.Parameters?.$filter)
+ const {
+ data = [],
+ isFetching,
+ error,
+ refetch,
+ } = useListDatatableQuery({ path, params: { $filter: graphFilter, ...params } })
+
+ let anonymized = false // Assuming default value is false
+ const regex = new RegExp('^[A-Z0-9]+$')
+ const principalNameOrUPN =
+ data[0]?.userPrincipalName ??
+ data[0]?.UPN ??
+ data[0]?.Owner ??
+ data.Results?.[0]?.upn ??
+ data.Results?.[0]?.userPrincipalName ??
+ data.Results?.[0]?.Owner
+
+ if (principalNameOrUPN && regex.test(principalNameOrUPN)) {
+ anonymized = true
+ }
+
+ var defaultFilterText = ''
+ if (params?.Parameters?.$filter) {
+ defaultFilterText = 'Graph: ' + params?.Parameters?.$filter
+ }
+ return (
+ <>
+ {anonymized && (
+
+ This table might contain anonymized data. Please check this
+
+ documentation link
+
+ to resolve this.
+
+ )}
+ {data?.Metadata?.Queued && {data?.Metadata?.QueueMessage} }
+ refetch()}
+ graphFilterFunction={setGraphFilter}
+ />
+ >
+ )
+}
+
+CippDatatable.propTypes = {
+ path: PropTypes.string.isRequired,
+ params: PropTypes.object,
+ ...CippTablePropTypes,
+}
diff --git a/src/components/utilities/CippAppPermissionBuilder.jsx b/src/components/utilities/CippAppPermissionBuilder.jsx
new file mode 100644
index 000000000000..8959b4f826fa
--- /dev/null
+++ b/src/components/utilities/CippAppPermissionBuilder.jsx
@@ -0,0 +1,1019 @@
+import React, { useEffect, useRef, useState, useCallback } from 'react'
+import {
+ CButton,
+ CCallout,
+ CCol,
+ CForm,
+ CRow,
+ CAccordion,
+ CAccordionHeader,
+ CAccordionBody,
+ CAccordionItem,
+ CTooltip,
+} from '@coreui/react'
+import { Field, Form, FormSpy } from 'react-final-form'
+import { RFFCFormRadioList, RFFSelectSearch } from 'src/components/forms'
+import { useGenericGetRequestQuery, useLazyGenericGetRequestQuery } from 'src/store/api/app'
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
+import {
+ TenantSelectorMultiple,
+ ModalService,
+ CippOffcanvas,
+ CippCodeBlock,
+} from 'src/components/utilities'
+import PropTypes from 'prop-types'
+import { OnChange } from 'react-final-form-listeners'
+import { useListTenantsQuery } from 'src/store/api/tenants'
+import CippButtonCard from 'src/components/contentcards/CippButtonCard'
+import { CippTable } from '../tables'
+import { Row } from 'react-bootstrap'
+import { cellGenericFormatter } from '../tables/CellGenericFormat'
+import Skeleton from 'react-loading-skeleton'
+import CippDropzone from './CippDropzone'
+import { Editor } from '@monaco-editor/react'
+import { useSelector } from 'react-redux'
+import { CippCallout } from '../layout'
+
+const CippAppPermissionBuilder = ({
+ onSubmit,
+ currentPermissions = {},
+ isSubmitting,
+ colSize = 8,
+ removePermissionConfirm = true,
+ appDisplayName = 'CIPP-SAM',
+}) => {
+ const [selectedApp, setSelectedApp] = useState([])
+ const [permissionsImported, setPermissionsImported] = useState(false)
+ const [newPermissions, setNewPermissions] = useState({})
+ const [importedManifest, setImportedManifest] = useState(null)
+ const [manifestVisible, setManifestVisible] = useState(false)
+ const currentTheme = useSelector((state) => state.app.currentTheme)
+ const [calloutMessage, setCalloutMessage] = useState(null)
+ const [initialPermissions, setInitialPermissions] = useState()
+
+ const {
+ data: servicePrincipals = [],
+ isFetching: spFetching,
+ isSuccess: spSuccess,
+ isUninitialized: spUninitialized,
+ refetch: refetchSpList,
+ } = useGenericGetRequestQuery({
+ path: 'api/ExecServicePrincipals',
+ })
+ const [createServicePrincipal, createResult] = useLazyGenericGetRequestQuery()
+
+ const removeServicePrincipal = (appId) => {
+ var servicePrincipal = selectedApp.find((sp) => sp?.appId === appId)
+ var newServicePrincipals = selectedApp.filter((sp) => sp?.appId !== appId)
+
+ if (removePermissionConfirm) {
+ ModalService.confirm({
+ title: 'Remove Service Principal',
+ body: `Are you sure you want to remove ${servicePrincipal.displayName}?`,
+ onConfirm: () => {
+ setSelectedApp(newServicePrincipals)
+ var updatedPermissions = JSON.parse(JSON.stringify(newPermissions))
+ delete updatedPermissions.Permissions[appId]
+ setNewPermissions(updatedPermissions)
+ },
+ })
+ } else {
+ setSelectedApp(newServicePrincipals)
+ var updatedPermissions = JSON.parse(JSON.stringify(newPermissions))
+ delete updatedPermissions.Permissions[appId]
+ setNewPermissions(updatedPermissions)
+ }
+ }
+
+ const confirmReset = () => {
+ if (removePermissionConfirm) {
+ ModalService.confirm({
+ title: 'Reset to Default',
+ body: 'Are you sure you want to reset all permissions to default?',
+ onConfirm: () => {
+ setSelectedApp([])
+ setPermissionsImported(false)
+ setManifestVisible(false)
+ setCalloutMessage('Permissions reset to default.')
+ },
+ })
+ } else {
+ setSelectedApp([])
+ setPermissionsImported(false)
+ setManifestVisible(false)
+ setCalloutMessage('Permissions reset to default.')
+ }
+ }
+
+ const handleSubmit = (values) => {
+ if (onSubmit) {
+ var postBody = {
+ Permissions: newPermissions.Permissions,
+ }
+ onSubmit(postBody)
+ }
+ }
+
+ const onCreateServicePrincipal = (appId) => {
+ createServicePrincipal({
+ path: 'api/ExecServicePrincipals?Action=Create&AppId=' + appId,
+ }).then(() => {
+ refetchSpList()
+ setCalloutMessage(createResult?.data?.Results)
+ })
+ }
+
+ const addPermissionRow = (servicePrincipal, permissionType, permission) => {
+ var updatedPermissions = JSON.parse(JSON.stringify(newPermissions))
+
+ if (!updatedPermissions?.Permissions[servicePrincipal]) {
+ updatedPermissions.Permissions[servicePrincipal] = {
+ applicationPermissions: [],
+ delegatedPermissions: [],
+ }
+ }
+ var currentPermission = updatedPermissions?.Permissions[servicePrincipal][permissionType]
+ var newPermission = []
+ if (currentPermission) {
+ currentPermission.map((perm) => {
+ if (perm.id !== permission.value) {
+ newPermission.push(perm)
+ }
+ })
+ }
+ newPermission.push({ id: permission.value, value: permission.label })
+
+ updatedPermissions.Permissions[servicePrincipal][permissionType] = newPermission
+ setNewPermissions(updatedPermissions)
+ }
+
+ const removePermissionRow = (servicePrincipal, permissionType, permissionId, permissionValue) => {
+ if (removePermissionConfirm) {
+ ModalService.confirm({
+ title: 'Remove Permission',
+ body: `Are you sure you want to remove the permission: ${permissionValue}?`,
+ onConfirm: () => {
+ var updatedPermissions = JSON.parse(JSON.stringify(newPermissions))
+ var currentPermission = updatedPermissions?.Permissions[servicePrincipal][permissionType]
+ var newPermission = []
+ if (currentPermission) {
+ currentPermission.map((perm) => {
+ if (perm.id !== permissionId) {
+ newPermission.push(perm)
+ }
+ })
+ }
+ updatedPermissions.Permissions[servicePrincipal][permissionType] = newPermission
+ setNewPermissions(updatedPermissions)
+ },
+ })
+ } else {
+ var updatedPermissions = JSON.parse(JSON.stringify(newPermissions))
+ var currentPermission = updatedPermissions?.Permissions[servicePrincipal][permissionType]
+ var newPermission = []
+ if (currentPermission) {
+ currentPermission.map((perm) => {
+ if (perm.id !== permissionId) {
+ newPermission.push(perm)
+ }
+ })
+ }
+ updatedPermissions.Permissions[servicePrincipal][permissionType] = newPermission
+ setNewPermissions(updatedPermissions)
+ }
+ }
+
+ const generateManifest = ({ appDisplayName = 'CIPP-SAM', prompt = false }) => {
+ if (prompt || appDisplayName === '') {
+ ModalService.prompt({
+ title: 'Generate Manifest',
+ body: 'Please enter the display name for the application.',
+ onConfirm: (value) => {
+ generateManifest({ appDisplayName: value })
+ },
+ })
+ } else {
+ var manifest = {
+ isFallbackPublicClient: true,
+ signInAudience: 'AzureADMultipleOrgs',
+ displayName: appDisplayName,
+ web: {
+ redirectUris: [
+ 'https://login.microsoftonline.com/common/oauth2/nativeclient',
+ 'https://localhost',
+ 'http://localhost',
+ 'http://localhost:8400',
+ ],
+ },
+ requiredResourceAccess: [],
+ }
+
+ var additionalPermissions = []
+
+ selectedApp.map((sp) => {
+ var appRoles = newPermissions?.Permissions[sp.appId]?.applicationPermissions
+ var delegatedPermissions = newPermissions?.Permissions[sp.appId]?.delegatedPermissions
+ var requiredResourceAccess = {
+ resourceAppId: sp.appId,
+ resourceAccess: [],
+ }
+ var additionalRequiredResourceAccess = {
+ resourceAppId: sp.appId,
+ resourceAccess: [],
+ }
+ if (appRoles) {
+ appRoles.map((role) => {
+ requiredResourceAccess.resourceAccess.push({
+ id: role.id,
+ type: 'Role',
+ })
+ })
+ }
+ if (delegatedPermissions) {
+ delegatedPermissions.map((perm) => {
+ // permission not a guid skip
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(perm.id)) {
+ requiredResourceAccess.resourceAccess.push({
+ id: perm.id,
+ type: 'Scope',
+ })
+ } else {
+ additionalRequiredResourceAccess.resourceAccess.push({
+ id: perm.id,
+ type: 'Scope',
+ })
+ }
+ })
+ }
+ if (requiredResourceAccess.resourceAccess.length > 0) {
+ manifest.requiredResourceAccess.push(requiredResourceAccess)
+ }
+ if (additionalRequiredResourceAccess.resourceAccess.length > 0) {
+ additionalPermissions.push(additionalRequiredResourceAccess)
+ }
+ })
+
+ var fileName = `${appDisplayName.replace(' ', '-')}.json`
+ if (appDisplayName === 'CIPP-SAM') {
+ fileName = 'SAMManifest.json'
+ }
+
+ var blob = new Blob([JSON.stringify(manifest, null, 2)], { type: 'application/json' })
+ var url = URL.createObjectURL(blob)
+ var a = document.createElement('a')
+ a.href = url
+ a.download = `${fileName}`
+ a.click()
+ URL.revokeObjectURL(url)
+
+ if (additionalPermissions.length > 0) {
+ ModalService.confirm({
+ title: 'Additional Permissions',
+ body: 'Some permissions are not supported in the manifest. Would you like to download them?',
+ confirmLabel: 'Download',
+ onConfirm: () => {
+ var additionalBlob = new Blob([JSON.stringify(additionalPermissions, null, 2)], {
+ type: 'application/json',
+ })
+ var additionalUrl = URL.createObjectURL(additionalBlob)
+ var additionalA = document.createElement('a')
+ additionalA.href = additionalUrl
+ additionalA.download = 'AdditionalPermissions.json'
+ additionalA.click()
+ URL.revokeObjectURL(additionalUrl)
+ },
+ })
+ }
+ }
+ }
+
+ const importManifest = () => {
+ var updatedPermissions = { Permissions: {} }
+ var manifest = importedManifest
+ var requiredResourceAccess = manifest.requiredResourceAccess
+ var selectedServicePrincipals = []
+
+ requiredResourceAccess.map((resourceAccess) => {
+ var sp = servicePrincipals?.Results?.find((sp) => sp.appId === resourceAccess.resourceAppId)
+ if (sp) {
+ var appRoles = []
+ var delegatedPermissions = []
+ selectedServicePrincipals.push(sp)
+ resourceAccess.resourceAccess.map((access) => {
+ if (access.type === 'Role') {
+ var role = sp.appRoles.find((role) => role.id === access.id)
+ if (role) {
+ appRoles.push({
+ id: role.id,
+ value: role.value,
+ })
+ }
+ } else if (access.type === 'Scope') {
+ var scope = sp.publishedPermissionScopes.find((scope) => scope.id === access.id)
+ if (scope) {
+ delegatedPermissions.push({
+ id: scope.id,
+ value: scope.value,
+ })
+ }
+ }
+ })
+ updatedPermissions.Permissions[sp.appId] = {
+ applicationPermissions: appRoles,
+ delegatedPermissions: delegatedPermissions,
+ }
+ }
+ })
+ setNewPermissions(updatedPermissions)
+ setSelectedApp(selectedServicePrincipals)
+ setImportedManifest(null)
+ setPermissionsImported(true)
+ setManifestVisible(false)
+ setCalloutMessage('Manifest imported successfully.')
+ }
+
+ const onManifestImport = useCallback((acceptedFiles) => {
+ acceptedFiles.forEach((file) => {
+ const reader = new FileReader()
+ reader.onabort = () => console.log('file reading was aborted')
+ reader.onerror = () => console.log('file reading has failed')
+ reader.onload = () => {
+ console.log(reader.result)
+ try {
+ var manifest = JSON.parse(reader.result)
+ setImportedManifest(manifest)
+ console.log(importedManifest)
+ } catch {
+ console.log('invalid manifest')
+ }
+ }
+ reader.readAsText(file)
+ })
+ }, [])
+
+ useEffect(() => {
+ try {
+ var initialAppIds = Object.keys(currentPermissions?.Permissions)
+ } catch {
+ initialAppIds = []
+ }
+
+ if (spSuccess && selectedApp.length == 0 && initialAppIds.length == 0) {
+ var microsoftGraph = servicePrincipals?.Results?.find(
+ (sp) => sp?.appId === '00000003-0000-0000-c000-000000000000',
+ )
+ setSelectedApp([microsoftGraph])
+ setNewPermissions({
+ Permissions: {
+ '00000003-0000-0000-c000-000000000000': {
+ applicationPermissions: [],
+ delegatedPermissions: [],
+ },
+ },
+ })
+ } else if (spSuccess & (currentPermissions !== initialPermissions)) {
+ setSelectedApp([])
+ setNewPermissions(currentPermissions)
+ setInitialPermissions(currentPermissions)
+ setPermissionsImported(false)
+ } else if (spSuccess && initialAppIds.length > 0 && permissionsImported == false) {
+ var newApps = []
+ initialAppIds?.map((appId) => {
+ var newSp = servicePrincipals?.Results?.find((sp) => sp.appId === appId)
+ if (newSp) {
+ newApps.push(newSp)
+ }
+ })
+ newApps = newApps.sort((a, b) => {
+ return a.displayName.localeCompare(b.displayName)
+ })
+ setSelectedApp(newApps)
+ setNewPermissions(currentPermissions)
+ setInitialPermissions(currentPermissions)
+ setPermissionsImported(true)
+ }
+ }, [
+ currentPermissions,
+ initialPermissions,
+ permissionsImported,
+ spSuccess,
+ selectedApp,
+ servicePrincipals,
+ setSelectedApp,
+ setPermissionsImported,
+ setNewPermissions,
+ setInitialPermissions,
+ ])
+
+ const ApiPermissionRow = ({ servicePrincipal = null }) => {
+ const {
+ data: servicePrincipalData = [],
+ isFetching: spFetching,
+ isSuccess: spIdSuccess,
+ } = useGenericGetRequestQuery({
+ path: 'api/ExecServicePrincipals?Id=' + servicePrincipal.id,
+ })
+
+ return (
+ <>
+ {spSuccess && servicePrincipal !== null && spIdSuccess && (
+
+
+
+
+
+ Manage the permissions for the {servicePrincipal.displayName}.
+
+
+
+
+
+ {
+ removeServicePrincipal(servicePrincipal.appId)
+ }}
+ disabled={servicePrincipal.appId === '00000003-0000-0000-c000-000000000000'}
+ className={`circular-button`}
+ >
+
+
+
+
+
+
+
+
+ {servicePrincipal?.appRoles?.length > 0 ? (
+ <>
+
+
+ {({ values }) => {
+ return (
+
+
+ {
+ return newPermissions?.Permissions[
+ servicePrincipal.appId
+ ]?.applicationPermissions?.find(
+ (perm) => perm.id === role.id,
+ )
+ ? false
+ : true
+ })
+ .map((role) => ({
+ name: role.value,
+ value: role.id,
+ }))
+ .sort((a, b) => a.name.localeCompare(b.name))}
+ />
+
+
+
+ {
+ addPermissionRow(
+ servicePrincipal.appId,
+ 'applicationPermissions',
+ values.Permissions[servicePrincipal?.appId]
+ .applicationPermissions,
+ )
+ values.Permissions[
+ servicePrincipal.appId
+ ].applicationPermissions = ''
+ }}
+ className={`circular-button`}
+ >
+
+
+
+
+
+ )
+ }}
+
+
+
+ row.value,
+ name: 'Permission',
+ sortable: true,
+ exportSelector: 'value',
+ maxWidth: '30%',
+ cell: cellGenericFormatter(),
+ },
+ {
+ selector: (row) => row.id,
+ name: 'Id',
+ omit: true,
+ exportSelector: 'id',
+ },
+ {
+ selector: (row) =>
+ servicePrincipalData?.Results?.appRoles.find(
+ (role) => role.id === row.id,
+ ).description,
+ name: 'Description',
+ cell: cellGenericFormatter({ wrap: true }),
+ maxWidth: '60%',
+ },
+ {
+ name: 'Actions',
+ cell: (row) => {
+ return (
+
+ {
+ removePermissionRow(
+ servicePrincipal.appId,
+ 'applicationPermissions',
+ row.id,
+ row.value,
+ )
+ }}
+ color="danger"
+ variant="ghost"
+ size="sm"
+ >
+
+
+
+ )
+ },
+ maxWidth: '10%',
+ },
+ ]}
+ dynamicColumns={false}
+ />
+
+ >
+ ) : (
+ <>
+
+
+ No Application Permissions found.
+
+ >
+ )}
+
+
+
+
+
+
+
+
+
+ {servicePrincipalData?.Results?.publishedPermissionScopes?.length == 0 && (
+
+
+ No Published Delegated Permissions found.
+
+ )}
+
+ {({ values }) => {
+ return (
+
+
+ 0
+ ? servicePrincipalData?.Results?.publishedPermissionScopes
+ .filter((scopes) => {
+ return newPermissions?.Permissions[
+ servicePrincipal.appId
+ ]?.delegatedPermissions?.find(
+ (perm) => perm.id === scopes.id,
+ )
+ ? false
+ : true
+ })
+ .map((scope) => ({
+ name: scope.value,
+ value: scope.id,
+ }))
+ .sort((a, b) => a.name.localeCompare(b.name))
+ : []
+ }
+ allowCreate={true}
+ />
+
+
+
+ {
+ addPermissionRow(
+ servicePrincipal.appId,
+ 'delegatedPermissions',
+ values?.Permissions[servicePrincipal.appId]
+ .delegatedPermissions,
+ )
+ values.Permissions[servicePrincipal.appId].delegatedPermissions =
+ ''
+ }}
+ className={`circular-button`}
+ >
+
+
+
+
+
+ )
+ }}
+
+
+
+ row?.value,
+ name: 'Permission',
+ sortable: true,
+ exportSelector: 'value',
+ maxWidth: '30%',
+ cell: cellGenericFormatter(),
+ },
+ {
+ selector: (row) => row?.id,
+ name: 'Id',
+ omit: true,
+ exportSelector: 'id',
+ },
+ {
+ selector: (row) =>
+ servicePrincipalData?.Results?.publishedPermissionScopes.find(
+ (scope) => scope?.id === row?.id,
+ )?.userConsentDescription ?? 'No Description',
+ name: 'Description',
+ cell: cellGenericFormatter({ wrap: true }),
+ maxWidth: '60%',
+ },
+ {
+ name: 'Actions',
+ cell: (row) => {
+ return (
+
+ {
+ removePermissionRow(
+ servicePrincipal.appId,
+ 'delegatedPermissions',
+ row.id,
+ row.value,
+ )
+ }}
+ color="danger"
+ variant="ghost"
+ size="sm"
+ >
+
+
+
+ )
+ },
+ maxWidth: '10%',
+ },
+ ]}
+ dynamicColumns={false}
+ />
+
+
+
+
+
+ )}
+ >
+ )
+ }
+ ApiPermissionRow.propTypes = {
+ servicePrincipal: PropTypes.object,
+ }
+
+ return (
+ <>
+ {spFetching && }
+ {spSuccess && (
+
+ handleReMediate()}>Remediate User
+ {!execRemediateResults.isSuccess && execRemediateResults.isError && (
+ Error. Could not remediate user
+ )}
+ {execRemediateResults.isFetching && (
+
+
+
+ )}
+ {execRemediateResults.isSuccess && (
+
+ {execRemediateResults.data?.Results.map((item, idx) => {
+ return {item}
+ })}
+
+ )}
+
+
+
+
+ {isFetching && }
+ {isSuccess && (
+
+ )}
+
+
+
+
+ {isFetching && }
+
+ {isSuccess && (
+
+ )}
+
+
+
+
+ {isFetching && }
+ {isSuccess && (
+
+ )}
+
+
+
+
+ {isFetching && }
+
+ {isSuccess && (
+
+ )}
+
+
+
+
+ {isFetching && }
+
+ {isSuccess && (
+
+ )}
+
+
+
+
+ {isFetching && }
+
+ {isSuccess && (
+
+ )}
+
+
+
+
+ {isFetching && }
+
+ {isSuccess && (
+
+ )}
+
+
+
+
+ {isFetching && }
+
+ {isSuccess && (
+
+ )}
+
+
+
+
+ )
+}
+
+export default ViewBec
diff --git a/src/views/identity/administration/ViewUser.jsx b/src/views/identity/administration/ViewUser.jsx
new file mode 100644
index 000000000000..a7530eacfbc2
--- /dev/null
+++ b/src/views/identity/administration/ViewUser.jsx
@@ -0,0 +1,114 @@
+import React, { useEffect, useState } from 'react'
+import { CSpinner } from '@coreui/react'
+import PropTypes from 'prop-types'
+import useQuery from 'src/hooks/useQuery'
+import { useDispatch } from 'react-redux'
+import { CippPage, CippMasonry, CippMasonryItem } from 'src/components/layout'
+import { ModalService } from 'src/components/utilities'
+import UserDevices from 'src/views/identity/administration/UserDevices'
+import UserDetails from 'src/views/identity/administration/UserDetails'
+import UserLastLoginDetails from 'src/views/identity/administration/UserLastLoginDetails'
+import UserCAPs from 'src/views/identity/administration/UserCAPs'
+import UserActions from 'src/views/identity/administration/UserActions'
+import UserOneDriveUsage from 'src/views/identity/administration/UserOneDriveUsage'
+import User365Management from 'src/views/identity/administration/User365Management'
+import UserEmailDetails from 'src/views/identity/administration/UserEmailDetails'
+import UserEmailUsage from 'src/views/identity/administration/UserEmailUsage'
+import UserEmailSettings from 'src/views/identity/administration/UserEmailSettings'
+import UserEmailPermissions from 'src/views/identity/administration/UserEmailPermissions'
+import UserGroups from 'src/views/identity/administration/UserGroups'
+import UserSigninLogs from 'src/views/identity/administration/UserSigninLogs'
+import UserMailboxRuleList from 'src/views/identity/administration/UserMailboxRuleList'
+import { useListUserQuery } from 'src/store/api/users'
+
+const ViewUser = (props) => {
+ const dispatch = useDispatch()
+ let query = useQuery()
+ const userId = query.get('userId')
+ const tenantDomain = query.get('tenantDomain')
+ const userEmail = query.get('userEmail')
+ const [queryError, setQueryError] = useState(false)
+
+ const {
+ data: user = {},
+ isFetching: userFetching,
+ error: userError,
+ } = useListUserQuery({ tenantDomain, userId })
+
+ useEffect(() => {
+ if (!userId || !tenantDomain) {
+ ModalService.open({
+ body: 'Error invalid request, could not load requested user.',
+ title: 'Invalid Request',
+ })
+ setQueryError(true)
+ }
+ }, [tenantDomain, userId, dispatch])
+
+ return (
+
+ {userFetching && }
+ {!userFetching && userError && Error loading user }
+ {!queryError && !userFetching && (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* // TODO: Add support for displaying the result of the delete operation. Currently, the delete operation is performed but the result is not displayed anywhere but the networking tab of the dev tools in the browser.
+ All API code is in place and should return the needed HTTP status information.
+ Possibly even remove the row in the table if the delete operation was successful? -Bobby */}
+
+
+
+ )}
+
+ )
+}
+
+ViewUser.propTypes = {
+ params: PropTypes.object,
+ location: PropTypes.object,
+}
+
+export default ViewUser
diff --git a/src/views/tenant/backup/CreateBackup.jsx b/src/views/tenant/backup/CreateBackup.jsx
new file mode 100644
index 000000000000..c4dbf63ba81c
--- /dev/null
+++ b/src/views/tenant/backup/CreateBackup.jsx
@@ -0,0 +1,271 @@
+import React, { useState } from 'react'
+import { CButton, CCallout, CCol, CForm, CRow, CSpinner, CTooltip } from '@coreui/react'
+import { useSelector } from 'react-redux'
+import { Field, Form } from 'react-final-form'
+import { RFFCFormSwitch } from 'src/components/forms'
+import {
+ useGenericGetRequestQuery,
+ useLazyGenericGetRequestQuery,
+ useLazyGenericPostRequestQuery,
+} from 'src/store/api/app'
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
+import { faCircleNotch, faEdit, faEye } from '@fortawesome/free-solid-svg-icons'
+import { CippPage, CippPageList } from 'src/components/layout'
+import 'react-datepicker/dist/react-datepicker.css'
+import { ModalService, TenantSelector } from 'src/components/utilities'
+import arrayMutators from 'final-form-arrays'
+import { useListConditionalAccessPoliciesQuery } from 'src/store/api/tenants'
+import CippButtonCard from 'src/components/contentcards/CippButtonCard'
+import { CellTip, cellGenericFormatter } from 'src/components/tables/CellGenericFormat'
+import { cellBadgeFormatter, cellDateFormatter } from 'src/components/tables'
+
+const CreateBackup = () => {
+ const [ExecuteGetRequest, getResults] = useLazyGenericGetRequestQuery()
+ const tenantDomain = useSelector((state) => state.app.currentTenant.defaultDomainName)
+ const [refreshState, setRefreshState] = useState(false)
+ const [genericPostRequest, postResults] = useLazyGenericPostRequestQuery()
+
+ const onSubmit = (values) => {
+ const startDate = new Date()
+ startDate.setHours(0, 0, 0, 0)
+ const unixTime = Math.floor(startDate.getTime() / 1000) - 45
+ const shippedValues = {
+ TenantFilter: tenantDomain,
+ Name: `CIPP Backup ${tenantDomain}`,
+ Command: { value: `New-CIPPBackup` },
+ Parameters: { backupType: 'Scheduled', ScheduledBackupValues: { ...values } },
+ ScheduledTime: unixTime,
+ Recurrence: { value: '1d' },
+ }
+ genericPostRequest({
+ path: '/api/AddScheduledItem?hidden=true&DisallowDuplicateName=true',
+ values: shippedValues,
+ }).then((res) => {
+ setRefreshState(res.requestId)
+ })
+ }
+ const Offcanvas = (row, rowIndex, formatExtraData) => {
+ const handleDeleteSchedule = (apiurl, message) => {
+ ModalService.confirm({
+ title: 'Confirm',
+ body: {message}
,
+ onConfirm: () =>
+ ExecuteGetRequest({ path: apiurl }).then((res) => {
+ setRefreshState(res.requestId)
+ }),
+ confirmLabel: 'Continue',
+ cancelLabel: 'Cancel',
+ })
+ }
+ let jsonResults
+ try {
+ jsonResults = JSON.parse(row.Results)
+ } catch (error) {
+ jsonResults = row.Results
+ }
+
+ return (
+ <>
+
+
+ handleDeleteSchedule(
+ `/api/RemoveScheduledItem?&ID=${row.RowKey}`,
+ 'Do you want to delete this job?',
+ )
+ }
+ size="sm"
+ variant="ghost"
+ color="danger"
+ >
+
+
+
+ >
+ )
+ }
+ const columns = [
+ {
+ name: 'Tenant',
+ selector: (row) => row['Tenant'],
+ sortable: true,
+ cell: (row) => CellTip(row['Tenant']),
+ exportSelector: 'Tenant',
+ },
+ {
+ name: 'Task State',
+ selector: (row) => row['TaskState'],
+ sortable: true,
+ cell: cellBadgeFormatter(),
+ exportSelector: 'TaskState',
+ },
+ {
+ name: 'Last executed time',
+ selector: (row) => row['ExecutedTime'],
+ sortable: true,
+ cell: cellDateFormatter({ format: 'relative' }),
+ exportSelector: 'ExecutedTime',
+ },
+ {
+ name: 'Actions',
+ cell: Offcanvas,
+ maxWidth: '100px',
+ },
+ ]
+
+ const {
+ data: users = [],
+ isFetching: usersIsFetching,
+ error: usersError,
+ } = useGenericGetRequestQuery({
+ path: '/api/ListGraphRequest',
+ params: {
+ TenantFilter: tenantDomain,
+ Endpoint: 'users',
+ $select: 'id,displayName,userPrincipalName,accountEnabled',
+ $count: true,
+ $top: 999,
+ $orderby: 'displayName',
+ },
+ })
+
+ const {
+ data: caPolicies = [],
+ isFetching: caIsFetching,
+ error: caError,
+ } = useListConditionalAccessPoliciesQuery({ domain: tenantDomain })
+
+ return (
+
+ <>
+
+
+
+ Create Backup Schedule
+ {postResults.isFetching && (
+
+ )}
+
+ }
+ title="Add backup Schedule"
+ icon={faEdit}
+ >
+ {
+ return (
+
+
+ Backups are stored in CIPPs storage and can be restored using the CIPP
+ Restore Backup Wizard. Backups run daily or on demand by clicking the backup
+ now button.
+
+
+
+ Tenant
+ {(props) => }
+
+
+
+
+
+
+
+ Identity
+
+
+ Conditional Access
+
+ Intune
+
+
+
+ Email Security
+
+
+ CIPP
+
+
+
+
+
+ {postResults.isSuccess && (
+
+ {postResults.data.Results}
+
+ )}
+ {getResults.isFetching && (
+
+ Loading
+
+ )}
+ {getResults.isSuccess && (
+ {getResults.data?.Results}
+ )}
+ {getResults.isError && (
+
+ Could not connect to API: {getResults.error.message}
+
+ )}
+
+ )
+ }}
+ />
+
+
+
+
+
+
+
+ >
+
+ )
+}
+
+export default CreateBackup
diff --git a/src/views/tenant/backup/RestoreBackup.jsx b/src/views/tenant/backup/RestoreBackup.jsx
new file mode 100644
index 000000000000..4091debf2881
--- /dev/null
+++ b/src/views/tenant/backup/RestoreBackup.jsx
@@ -0,0 +1,242 @@
+import React, { useState } from 'react'
+import { CCallout, CCol, CListGroup, CListGroupItem, CRow, CSpinner, CTooltip } from '@coreui/react'
+import { Field, FormSpy } from 'react-final-form'
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
+import { faExclamationTriangle, faTimes, faCheck } from '@fortawesome/free-solid-svg-icons'
+import { useSelector } from 'react-redux'
+import { CippCallout, CippWizard } from 'src/components/layout'
+import PropTypes from 'prop-types'
+import { Condition, RFFCFormSwitch, RFFSelectSearch } from 'src/components/forms'
+import { TenantSelector } from 'src/components/utilities'
+import { useGenericGetRequestQuery, useLazyGenericPostRequestQuery } from 'src/store/api/app'
+import 'react-datepicker/dist/react-datepicker.css'
+
+const Error = ({ name }) => (
+
+ touched && error ? (
+
+
+ {error}
+
+ ) : null
+ }
+ />
+)
+
+Error.propTypes = {
+ name: PropTypes.string.isRequired,
+}
+
+const OffboardingWizard = () => {
+ const tenantDomain = useSelector((state) => state.app.currentTenant.defaultDomainName)
+ const {
+ data: currentBackups = [],
+ isFetching: currentBackupsIsFetching,
+ error: currentBackupsError,
+ } = useGenericGetRequestQuery({
+ path: `/api/ExecListBackup?TenantFilter=${tenantDomain}&Type=Scheduled`,
+ })
+
+ const [genericPostRequest, postResults] = useLazyGenericPostRequestQuery()
+
+ const handleSubmit = (values) => {
+ const startDate = new Date()
+ const unixTime = Math.floor(startDate.getTime() / 1000) - 45
+ const shippedValues = {
+ TenantFilter: tenantDomain,
+ Name: `CIPP Restore ${tenantDomain}`,
+ Command: { value: `New-CIPPRestore` },
+ Parameters: { Type: 'Scheduled', RestoreValues: { ...values } },
+ ScheduledTime: unixTime,
+ PostExecution: {
+ Webhook: values.webhook,
+ Email: values.email,
+ PSA: values.psa,
+ },
+ }
+ genericPostRequest({ path: '/api/AddScheduledItem', values: shippedValues }).then((res) => {})
+ }
+
+ return (
+
+
+
+ Step 1
+ Choose a tenant
+
+
+ {(props) => }
+
+
+
+
+ Step 2
+ Select the backup to restore
+
+
+
+ ({
+ value: backup.RowKey,
+ name: `${backup.RowKey}`,
+ }))}
+ placeholder={!currentBackupsIsFetching ? 'Select a backup' : 'Loading...'}
+ name="backup"
+ />
+ {currentBackupsError && Failed to load list of Current Backups }
+
+
+
+
+
+ Step 3
+ Choose restore options
+
+
+
+
+
+ Identity
+
+
+ Conditional Access
+
+ Intune
+
+
+
+
+
+ Email Security
+
+
+ CIPP
+
+
+
+
+
+
+
+
+
+
+
+
+ Warning
+
+ Overwriting existing entries will remove the current settings and replace them
+ with the backup settings. If you have selected to restore users, all properties
+ will be overwritten with the backup settings.
+
+
+
+ To prevent and skip already existing entries, deselect the setting from the list
+ above, or disable overwrite.
+
+
+
+
+
+
+
+ Send Restore results to:
+
+
+
+
+
+
+
+
+
+
+ Step 4
+ Confirm and apply
+
+
+
+ {postResults.isFetching && (
+
+ Loading
+
+ )}
+ {postResults.isSuccess && {postResults.data.Results} }
+ {!postResults.isSuccess && (
+
+ {/* eslint-disable react/prop-types */}
+ {(props) => (
+ <>
+
+
+
+
+ Selected Tenant:
+ {tenantDomain}
+
+
+ Selected Backup:
+ {props.values.backup.value}
+
+
+
+
+
+
+
+
+
+ Overwrite existing configuration
+
+
+
+ Send results to Webhook
+
+
+
+ Send results to E-Mail
+
+
+
+ Send results to PSA
+
+
+
+
+
+ >
+ )}
+
+ )}
+
+
+
+
+ )
+}
+
+export default OffboardingWizard
diff --git a/src/views/tenant/standards/BestPracticeAnalyser.jsx b/src/views/tenant/standards/BestPracticeAnalyser.jsx
new file mode 100644
index 000000000000..692c39478591
--- /dev/null
+++ b/src/views/tenant/standards/BestPracticeAnalyser.jsx
@@ -0,0 +1,471 @@
+import React, { useEffect, useState } from 'react'
+import {
+ CBadge,
+ CButton,
+ CCard,
+ CCardBody,
+ CCardHeader,
+ CCardText,
+ CCardTitle,
+ CCol,
+ CCollapse,
+ CForm,
+ CRow,
+ CSpinner,
+} from '@coreui/react'
+import useQuery from 'src/hooks/useQuery'
+import PropTypes from 'prop-types'
+import { Field, Form, FormSpy } from 'react-final-form'
+import { RFFCFormInput, RFFCFormSelect } from 'src/components/forms'
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
+import {
+ faChevronRight,
+ faChevronDown,
+ faSearch,
+ faExclamationTriangle,
+ faCheck,
+ faCross,
+ faTimes,
+ faSync,
+ faExclamation,
+} from '@fortawesome/free-solid-svg-icons'
+import { CippTable, cellBooleanFormatter } from 'src/components/tables'
+import { useSelector } from 'react-redux'
+import { useNavigate } from 'react-router-dom'
+import { CippPage } from 'src/components/layout/CippPage'
+import { useGenericGetRequestQuery, useLazyGenericGetRequestQuery } from 'src/store/api/app'
+import { OnChange } from 'react-final-form-listeners'
+import { queryString } from 'src/helpers'
+import { CellTip, cellGenericFormatter } from 'src/components/tables/CellGenericFormat'
+import { useExecBestPracticeAnalyserMutation } from 'src/store/api/reports'
+import { ModalService } from 'src/components/utilities'
+import { cellTableFormatter } from 'src/components/tables/CellTable'
+import { cellMathFormatter } from 'src/components/tables/CellMathFormatter'
+
+const RefreshAction = ({ singleTenant = false, refreshFunction = null }) => {
+ const tenantDomain = useSelector((state) => state.app.currentTenant.defaultDomainName)
+ const [execBestPracticeAnalyser, { isLoading, isSuccess, error }] =
+ useLazyGenericGetRequestQuery()
+ var params = {}
+ if (singleTenant) {
+ params['TenantFilter'] = tenantDomain
+ }
+ const showModal = () =>
+ ModalService.confirm({
+ body: (
+
+ Are you sure you want to force the Best Practice Analysis to run? This will slow down
+ normal usage considerably.
+ Please note: this runs at 3:00 AM UTC automatically every day.
+
+ ),
+ onConfirm: () =>
+ execBestPracticeAnalyser({
+ path: 'api/ExecBPA',
+ params: params,
+ }),
+ })
+
+ return (
+ <>
+
+ {isLoading && }
+ {error && }
+ {isSuccess && }
+ {(singleTenant && 'Refresh Tenant Data') || 'Force Refresh All Data'}
+
+ {refreshFunction !== null && (
+ {
+ refreshFunction((Math.random() + 1).toString(36).substring(7))
+ }}
+ className="m-1"
+ size="sm"
+ >
+
+
+ )}
+ >
+ )
+}
+RefreshAction.propTypes = {
+ singleTenant: PropTypes.bool,
+ refreshFunction: PropTypes.func,
+}
+
+const getsubcolumns = (data) => {
+ const flatObj = data && data.length > 0 ? data : [{ data: 'No Data Found' }]
+ const QueryColumns = []
+
+ if (flatObj[0]) {
+ Object.keys(flatObj[0]).map((key) => {
+ QueryColumns.push({
+ name: key,
+ selector: (row) => row[key], // Accessing the property using the key
+ sortable: true,
+ exportSelector: key,
+ cell: cellGenericFormatter(),
+ })
+ })
+ }
+ return QueryColumns
+}
+const getNestedValue = (obj, path) => {
+ if (!path) return undefined
+ return path.split('.').reduce((acc, part) => {
+ // Check for an array marker
+ const match = part.match(/(.*?)\[(\d+)\]/)
+ if (match) {
+ const propName = match[1]
+ const index = parseInt(match[2], 10)
+ return acc[propName] ? acc[propName][index] : undefined
+ }
+ // If no array marker, simply return the property value
+ return acc ? acc[part] : undefined
+ }, obj)
+}
+const BestPracticeAnalyser = () => {
+ const [reportTemplate, setReportTemplate] = useState('CIPP Best Practices v1.0 - Table view')
+ const [refreshValue, setRefreshValue] = useState('')
+ const { data: templates = [], isLoading: templatesfetch } = useGenericGetRequestQuery({
+ path: 'api/listBPATemplates',
+ })
+ let navigate = useNavigate()
+ const tenant = useSelector((state) => state.app.currentTenant)
+ let query = useQuery()
+ const Report = query.get('Report')
+ const SearchNow = query.get('SearchNow')
+ const [visibleA, setVisibleA] = useState(true)
+ const handleSubmit = async (values) => {
+ setVisibleA(false)
+ setReportTemplate(values.reportTemplate)
+ const shippedValues = {
+ SearchNow: true,
+ Report: reportTemplate,
+ tenantFilter: tenant.customerId,
+ random: (Math.random() + 1).toString(36).substring(7),
+ }
+ var queryString = Object.keys(shippedValues)
+ .map((key) => key + '=' + shippedValues[key])
+ .join('&')
+
+ navigate(`?${queryString}`)
+ }
+ const [execGraphRequest, graphrequest] = useLazyGenericGetRequestQuery()
+ const QueryColumns = {
+ set: false,
+ data: [
+ {
+ name: 'Tenant',
+ selector: (row) => row['Tenant'],
+ sortable: true,
+ exportSelector: 'Tenant',
+ cell: (row) => CellTip(row['Tenant']),
+ },
+ ],
+ }
+ const normalizeTableData = (value) => {
+ if (Array.isArray(value)) {
+ return value
+ } else if (value === null) {
+ return null
+ } else if (typeof value === 'object') {
+ return [value]
+ } else {
+ return value
+ }
+ }
+ if (graphrequest.isSuccess) {
+ if (graphrequest.data.length === 0) {
+ graphrequest.data = [{ data: 'No Data Found' }]
+ }
+ const flatObj = graphrequest.data.Columns.length >= 0 ? graphrequest.data.Columns : []
+
+ flatObj.map((col) => {
+ if (col === null) {
+ return
+ }
+ // Determine the cell selector based on the 'formatter' property
+ let cellSelector
+ if (col.formatter) {
+ switch (col.formatter) {
+ case 'bool':
+ cellSelector = cellBooleanFormatter()
+ break
+ case 'reverseBool':
+ cellSelector = cellBooleanFormatter({ reverse: true })
+ break
+ case 'warnBool':
+ cellSelector = cellBooleanFormatter({ warning: true })
+ break
+ case 'table':
+ cellSelector = cellTableFormatter(col.value)
+ break
+ case 'math':
+ cellSelector = cellMathFormatter({ col })
+ break
+ default:
+ cellSelector = cellGenericFormatter()
+ break
+ }
+ } else {
+ cellSelector = cellGenericFormatter()
+ }
+
+ QueryColumns.data.push({
+ name: col.name,
+ selector: (row) => getNestedValue(row, col.value),
+ sortable: true,
+ exportSelector: col.value.split('.').join('/'),
+ cell: cellSelector, // Use the determined cell selector
+ })
+ })
+
+ QueryColumns.set = true
+ }
+
+ useEffect(() => {
+ execGraphRequest({
+ path: 'api/listBPA',
+ params: {
+ tenantFilter: tenant.customerId,
+ Report: reportTemplate,
+ SearchNow: SearchNow,
+ refresh: refreshValue,
+ },
+ })
+ }, [
+ Report,
+ execGraphRequest,
+ tenant.defaultDomainName,
+ query,
+ refreshValue,
+ reportTemplate,
+ tenant.customerId,
+ SearchNow,
+ ])
+ return (
+ <>
+
+
+
+
+
+ Report Settings
+ setVisibleA(!visibleA)}
+ >
+
+
+
+
+
+
+
+
+
+ {
+ return (
+
+
+
+ ({
+ label: template.Name,
+ value: template.Name,
+ }))}
+ />
+
+ {templatesfetch && }
+
+
+
+
+
+
+ Retrieve Report
+
+
+
+
+ )
+ }}
+ />
+
+
+
+
+
+
+
+
+
+ {graphrequest.isUninitialized && Choose a BPA Report to get started. }
+ {graphrequest.isFetching && }
+ {graphrequest.isSuccess && QueryColumns.set && graphrequest.data.Style == 'Table' && (
+
+
+ Best Practice Report
+
+
+ ,
+ ],
+ }}
+ />
+
+
+ )}
+ {graphrequest.isSuccess && QueryColumns.set && graphrequest.data.Style == 'Tenant' && (
+ <>
+
+
+
+
+ {graphrequest.data?.Data[0] &&
+ Object.keys(graphrequest.data.Data[0]).length === 0 ? (
+
+
+ Best Practice Report
+
+
+
+ No Data Found for this tenant. Please refresh the tenant data.
+
+
+
+ ) : (
+ graphrequest.data.Columns.map((info, idx) => (
+
+
+
+ {info.name}
+
+
+
+ {info.formatter === 'bool' && (
+
+
+ {graphrequest.data.Data[0][info.value] ? 'Yes' : 'No'}
+
+ )}
+ {info.formatter === 'reverseBool' && (
+
+
+ {graphrequest.data.Data[0][info.value] ? 'No' : 'Yes'}
+
+ )}
+ {info.formatter === 'warnBool' && (
+
+
+ {graphrequest.data.Data[0][info.value] ? 'Yes' : 'No'}
+
+ )}
+
+ {info.formatter === 'table' && (
+ <>
+
+ >
+ )}
+
+ {info.formatter === 'number' && (
+
+ {getNestedValue(graphrequest.data.Data[0], info.value)}
+
+ )}
+
+
+ {info.desc}
+
+
+
+
+ ))
+ )}
+
+ >
+ )}
+
+
+
+ >
+ )
+}
+
+export default BestPracticeAnalyser
diff --git a/version_latest.txt b/version_latest.txt
index 5e39348ef037..2e0b229ff633 100644
--- a/version_latest.txt
+++ b/version_latest.txt
@@ -1 +1,5 @@
-99.99.99
\ No newline at end of file
+<<<<<<< HEAD
+6.4.1
+=======
+99.99.99
+>>>>>>> 81ee51ffa0576b02dc60ee5600a44cec3cde3e97