diff --git a/package.json b/package.json index 067c5e36c592..810e9acbc6ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cipp", - "version": "10.9.1", + "version": "10.10.1", "author": "CIPP Contributors", "homepage": "https://cipp.app/", "bugs": { @@ -8,7 +8,7 @@ }, "license": "AGPL-3.0", "engines": { - "node": "^22.22.2" + "node": "^22.22.0" }, "repository": { "type": "git", @@ -38,7 +38,7 @@ "@emotion/react": "11.14.0", "@emotion/server": "11.11.0", "@emotion/styled": "11.14.1", - "@floating-ui/dom": "1.7.6", + "@floating-ui/dom": "1.8.0", "@heroicons/react": "2.2.0", "@monaco-editor/react": "^4.6.0", "@mui/icons-material": "9.4.0", @@ -64,7 +64,7 @@ "@tiptap/react": "^3.31.3", "@tiptap/starter-kit": "^3.31.3", "@vvo/tzdb": "^6.198.0", - "apexcharts": "6.6.1", + "apexcharts": "7.1.0", "axios": "1.18.1", "date-fns": "4.4.0", "diff": "^9.0.0", @@ -123,7 +123,7 @@ }, "devDependencies": { "@storybook/addon-a11y": "10.3.5", - "@storybook/addon-docs": "10.3.5", + "@storybook/addon-docs": "10.5.10", "@storybook/addon-themes": "10.3.5", "@storybook/addon-vitest": "10.3.5", "@storybook/react-vite": "10.3.5", @@ -137,12 +137,12 @@ "@vitest/browser-playwright": "4.1.10", "@vitest/coverage-v8": "4.1.10", "eslint": "^9.39.4", - "eslint-config-next": "^16.2.10", + "eslint-config-next": "^16.3.4", "eslint-config-prettier": "^10.1.8", - "jsdom": "30.0.1", + "jsdom": "29.0.1", "msw": "2.15.0", "msw-storybook-addon": "3.0.0", - "playwright": "1.59.1", + "playwright": "1.63.0", "prettier": "^3.9.6", "storybook": "10.5.10", "typescript": "5.9.3", diff --git a/public/version.json b/public/version.json index d8df8939c3a4..3b88d3c7ee3b 100644 --- a/public/version.json +++ b/public/version.json @@ -1,3 +1,3 @@ { - "version": "10.10.0" -} + "version": "10.10.1" +} \ No newline at end of file diff --git a/src/components/CippComponents/CippPolicyImportDrawer.jsx b/src/components/CippComponents/CippPolicyImportDrawer.jsx index eaec68f0bbb0..1751bb7e4b66 100644 --- a/src/components/CippComponents/CippPolicyImportDrawer.jsx +++ b/src/components/CippComponents/CippPolicyImportDrawer.jsx @@ -77,6 +77,13 @@ const modeConfig = { tenantSource: false, relatedQueryKeys: ['ListDlpCompliancePolicyTemplates'], }, + PIMRoleSettings: { + label: 'PIM Role Settings', + types: ['PIMRoleSettingsTemplate'], + tenantSource: false, + // The list queryKey is tenant-suffixed (ListPIMRoleSettingsTemplates-), so match with a wildcard. + relatedQueryKeys: ['ListPIMRoleSettingsTemplates*'], + }, } export const CippPolicyImportDrawer = ({ diff --git a/src/components/CippComponents/CippUserActions.jsx b/src/components/CippComponents/CippUserActions.jsx index 6b767ba2706b..09316ed884be 100644 --- a/src/components/CippComponents/CippUserActions.jsx +++ b/src/components/CippComponents/CippUserActions.jsx @@ -867,6 +867,9 @@ export const useCippUserActions = () => { username: 'userPrincipalName', userid: 'id', }, + defaultvalues: { + destination: { label: 'OneDrive root', value: 'root' }, + }, fields: [ { type: 'autoComplete', @@ -883,8 +886,34 @@ export const useCippUserActions = () => { queryKey: `sharepointSites-${tenant}`, }, }, + { + type: 'autoComplete', + name: 'destination', + label: 'Shortcut location', + multiple: false, + creatable: false, + options: [ + { label: 'OneDrive root', value: 'root' }, + { label: 'Shortcuts folder (Microsoft UI)', value: 'shortcuts' }, + ], + validators: { required: 'Please select a shortcut location' }, + }, ], - confirmText: 'Select a SharePoint site to create a shortcut for:', + confirmText: 'Select a SharePoint site and where to create the OneDrive shortcut:', + multiPost: false, + condition: () => canWriteUser, + }, + { + label: 'Migrate OneDrive Shortcuts', + type: 'POST', + icon: , + url: '/api/ExecMigrateOneDriveShortCuts', + data: { + username: 'userPrincipalName', + userid: 'id', + }, + confirmText: + 'Migrate root OneDrive shortcuts for [userPrincipalName] into the Shortcuts folder?', multiPost: false, condition: () => canWriteUser, }, diff --git a/src/data/standards.json b/src/data/standards.json index 5e79d23e0fcc..c645548e7d22 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -5395,6 +5395,29 @@ "ONEDRIVEENTERPRISE" ] }, + { + "name": "standards.MigrateOneDriveShortcuts", + "cat": "SharePoint Standards", + "tag": [], + "helpText": "Finds SharePoint library shortcuts sitting in each user's OneDrive root and moves them into the Shortcuts folder (PATCH move into special/shortcuts), matching the optional Microsoft UI location.", + "docsDescription": "Over time Add shortcut to OneDrive can leave many remote library links in the OneDrive root. Microsoft also supports placing those links in an optional Shortcuts folder. This standard lists each enabled member user's OneDrive root with Prefer Include-Feature=AddToOneDrive, then for any remoteItem shortcuts still outside Shortcuts moves them into special/shortcuts. Users without a provisioned OneDrive are skipped. Failures name the user, shortcut, and site URL when available.", + "executiveText": "Keeps employee OneDrive roots tidy by moving SharePoint library shortcuts into the dedicated Shortcuts folder instead of leaving them scattered among personal files.", + "addedComponent": [], + "label": "Migrate OneDrive root shortcuts to the Shortcuts folder", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2026-09-08", + "powershellEquivalent": "PATCH drive/items/{id} parentReference → special/shortcuts", + "recommendedBy": [], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "SHAREPOINTENTERPRISE_GOV", + "ONEDRIVEENTERPRISE" + ] + }, { "name": "standards.SPFileRequests", "cat": "SharePoint Standards", diff --git a/src/pages/cipp/advanced/container-management/worker-health.jsx b/src/pages/cipp/advanced/container-management/worker-health.jsx index 67bf10c1c163..5c742f433dd9 100644 --- a/src/pages/cipp/advanced/container-management/worker-health.jsx +++ b/src/pages/cipp/advanced/container-management/worker-health.jsx @@ -548,6 +548,71 @@ const CompactStatsRow = ({ snapshot }) => { ); }; +// OS-thread breakdown from the MemoryDetail bridge — a point-in-time read of how many +// threads the process holds and what they are doing. The PowerShell worker pool +// (HTTP + BG) dominates the count and stays bounded, so ThreadCount next to TotalWorkers +// is the direct "nothing is leaking threads" read for operators. +const ThreadStatsCard = ({ detail }) => { + if (!detail) return null; + + const states = detail.ThreadStates || {}; + const stateEntries = Object.entries(states).sort((a, b) => b[1] - a[1]); + + const poolStats = [ + { k: "Processors", v: detail.ProcessorCount ?? 0 }, + { k: "HTTP Workers", v: detail.HttpWorkers ?? 0 }, + { k: "BG Workers", v: detail.BgWorkers ?? 0 }, + { k: "Total Workers", v: detail.TotalWorkers ?? 0 }, + ]; + + return ( + + } + subheader={`${detail.ThreadCount ?? 0} OS threads • ${detail.TotalWorkers ?? 0} pooled workers across ${detail.ProcessorCount ?? 0} processors`} + action={} + slotProps={{ + title: { variant: "h6" }, + subheader: { variant: "caption" }, + }} + /> + + 0 ? 2 : 0 }} + > + {poolStats.map((s) => ( + + ))} + + {stateEntries.length > 0 && ( + <> + + Thread states + + + {stateEntries.map(([state, count]) => ( + + ))} + + + )} + + + ); +}; + // CippInfoBar renders its value slot nowrap + ellipsis; a block child re-enables wrapping // so long memory/CPU strings stay whole instead of truncating mid-number. const WrappedStat = ({ children }) => ( @@ -641,9 +706,19 @@ const Page = () => { refetchInterval: effectivePaused ? false : 30000, }); + // MemoryDetail is heavier than the 5s snapshot (it enumerates threads + assemblies), so + // poll it on the slower 30s cadence — the thread count it carries is a stable diagnostic. + const memoryDetailQuery = ApiGetCall({ + url: "/api/ListWorkerHealth", + data: { Action: "MemoryDetail" }, + queryKey: "WorkerMemoryDetail", + refetchInterval: effectivePaused ? false : 30000, + }); + // Resolve data: imported overrides live const snapshot = isImported ? importedData.snapshot : healthQuery.data?.Results; const startupInfo = isImported ? importedData.startup : startupQuery.data?.Results; + const memoryDetail = isImported ? importedData.memoryDetail : memoryDetailQuery.data?.Results; const importedJobs = useMemo(() => { if (!isImported || !importedData.jobs) return null; // Handle both array and { Results: [...] } shapes from query cache @@ -673,6 +748,7 @@ const Page = () => { snapshot: healthQuery.data?.Results ?? null, startup: startupQuery.data?.Results ?? null, history: historyQuery.data?.Results ?? null, + memoryDetail: memoryDetailQuery.data?.Results ?? null, jobs: null, }; // Grab the job data for the currently selected limit/status only — a prefix match @@ -697,6 +773,7 @@ const Page = () => { healthQuery.data, startupQuery.data, historyQuery.data, + memoryDetailQuery.data, historyRange, queryClient, jobLimit, @@ -933,6 +1010,9 @@ const Page = () => { {/* ── Compact pool / jobs / limiter stats ── */} + {/* ── OS thread breakdown ── */} + + {/* ── Worker tables ── */} diff --git a/src/pages/identity/administration/roles/templates/index.jsx b/src/pages/identity/administration/roles/templates/index.jsx index 04e88163861f..d5d876bed94e 100644 --- a/src/pages/identity/administration/roles/templates/index.jsx +++ b/src/pages/identity/administration/roles/templates/index.jsx @@ -5,10 +5,12 @@ import { ApiGetCall } from '../../../../../api/ApiCall' import { useSettings } from '../../../../../hooks/use-settings' import { CippTablePage } from '../../../../../components/CippComponents/CippTablePage.jsx' import { CippPropertyListCard } from '../../../../../components/CippCards/CippPropertyListCard' +import { CippPolicyImportDrawer } from '../../../../../components/CippComponents/CippPolicyImportDrawer.jsx' import { Layout as DashboardLayout } from '../../../../../layouts/index' import { TabbedLayout } from '../../../../../layouts/TabbedLayout' import { getCippTranslation } from '../../../../../utils/get-cipp-translation' import { getCippFormatting } from '../../../../../utils/get-cipp-formatting' +import { PermissionButton } from '../../../../../utils/permissions' import tabOptions from '../tabOptions.json' const Page = () => { @@ -120,13 +122,21 @@ const Page = () => { tenantInTitle={false} actions={actions} cardButton={ - + <> + + + } offCanvas={offCanvas} simpleColumns={[ diff --git a/src/pages/identity/administration/users/user/onedrive-shortcuts.jsx b/src/pages/identity/administration/users/user/onedrive-shortcuts.jsx new file mode 100644 index 000000000000..ed23ef80dab8 --- /dev/null +++ b/src/pages/identity/administration/users/user/onedrive-shortcuts.jsx @@ -0,0 +1,196 @@ +import { useEffect, useState } from 'react' +import { Button, Box } from '@mui/material' +import { useRouter } from 'next/router' +import ReactTimeAgo from 'react-time-ago' +import { Layout as DashboardLayout } from '../../../../../layouts/index' +import { HeaderedTabbedLayout } from '../../../../../layouts/HeaderedTabbedLayout' +import { CippIcons } from '../../../../../utils/icon-registry' +import { useSettings } from '../../../../../hooks/use-settings' +import { useDialog } from '../../../../../hooks/use-dialog' +import { usePermissions } from '../../../../../hooks/use-permissions' +import { ApiGetCall } from '../../../../../api/ApiCall' +import CippFormSkeleton from '../../../../../components/CippFormPages/CippFormSkeleton' +import { CippUserSwitcher } from '../../../../../components/CippComponents/CippUserSwitcher' +import { CippCopyToClipBoard } from '../../../../../components/CippComponents/CippCopyToClipboard' +import { CippDataTable } from '../../../../../components/CippTable/CippDataTable' +import { CippApiDialog } from '../../../../../components/CippComponents/CippApiDialog' +import { CippHead } from '../../../../../components/CippComponents/CippHead' +import tabOptions from './tabOptions' + +const Page = () => { + const userSettingsDefaults = useSettings() + const router = useRouter() + const { userId } = router.query + const tenant = router.query.tenantFilter ?? userSettingsDefaults.currentTenant + const [waiting, setWaiting] = useState(false) + const addDialog = useDialog() + const { checkPermissions } = usePermissions() + const canWriteUser = checkPermissions(['Identity.User.ReadWrite']) + + useEffect(() => { + if (userId) setWaiting(true) + }, [userId]) + + const userRequest = ApiGetCall({ + url: `/api/ListUsers?UserId=${userId}&tenantFilter=${tenant}`, + queryKey: `ListUsers-${userId}`, + waiting, + }) + + const user = userRequest.data?.[0] + const userPrincipalName = user?.userPrincipalName + const shortcutsQueryKey = `ListUserOneDriveShortcuts-${tenant}-${userId}` + + const title = userRequest.isSuccess ? user?.displayName : 'Loading...' + const subtitle = userRequest.isSuccess + ? [ + { + icon: , + text: , + }, + { + icon: , + text: , + }, + { + icon: , + text: ( + <> + Created: + + ), + }, + ] + : [] + + const actions = [ + { + label: 'Migrate to Shortcuts folder', + type: 'POST', + icon: , + url: '/api/ExecMigrateOneDriveShortCuts', + data: { + id: 'id', + username: 'userPrincipalName', + }, + relatedQueryKeys: [shortcutsQueryKey], + confirmText: 'Move OneDrive shortcut [name] from the root into the Shortcuts folder?', + condition: (row) => canWriteUser && row?.location === 'OneDrive root', + }, + { + label: 'Remove Shortcut', + type: 'POST', + icon: , + url: '/api/ExecRemoveOneDriveShortCut', + data: { + id: 'id', + name: 'name', + username: 'userPrincipalName', + }, + relatedQueryKeys: [shortcutsQueryKey], + confirmText: 'Remove OneDrive shortcut [name] for this user?', + color: 'danger', + condition: () => canWriteUser, + }, + ] + + return ( + <> + + + } + subtitle={subtitle} + isFetching={userRequest.isLoading} + > + {userRequest.isLoading && } + {userRequest.isSuccess && ( + + } + onClick={addDialog.handleOpen} + > + Add Shortcut + + ) : null + } + api={{ + url: '/api/ListUserOneDriveShortcuts', + data: { + userId, + userPrincipalName, + tenantFilter: tenant, + }, + }} + /> + + )} + + {user && ( + + )} + + ) +} + +Page.getLayout = (page) => {page} + +export default Page diff --git a/src/pages/identity/administration/users/user/tabOptions.json b/src/pages/identity/administration/users/user/tabOptions.json index 61fd2ffbe806..6cba061e6d5e 100644 --- a/src/pages/identity/administration/users/user/tabOptions.json +++ b/src/pages/identity/administration/users/user/tabOptions.json @@ -14,6 +14,11 @@ "path": "/identity/administration/users/user/exchange", "icon": "Mail" }, + { + "label": "OneDrive Shortcuts", + "path": "/identity/administration/users/user/onedrive-shortcuts", + "icon": "Shortcut" + }, { "label": "Compromise Remediation", "path": "/identity/administration/users/user/bec", diff --git a/src/pages/tools/community-repos/index.jsx b/src/pages/tools/community-repos/index.jsx index 02b149155a70..e0f74b7da10c 100644 --- a/src/pages/tools/community-repos/index.jsx +++ b/src/pages/tools/community-repos/index.jsx @@ -44,6 +44,7 @@ const typeOptions = [ { label: "Baseline", value: "BaselineTemplate" }, { label: "Report Builder", value: "ReportBuilderTemplate" }, { label: "Group", value: "GroupTemplate" }, + { label: "PIM Role Settings", value: "PIMRoleSettingsTemplate" }, { label: "Custom Test", value: "CustomTest" }, ]; diff --git a/src/pages/unauthenticated.jsx b/src/pages/unauthenticated.jsx index 59e2449d5b74..82899474c86a 100644 --- a/src/pages/unauthenticated.jsx +++ b/src/pages/unauthenticated.jsx @@ -107,6 +107,15 @@ const Page = ({ reason = 'session' }) => { const hasIdentity = hasAuthIdentity(swaStatus?.data) const isSessionEnded = reason === 'session' && !(hasIdentity && orgData?.data?.message) + // Render the shell as soon as the auth state has settled either way. Gating on + // isSuccess alone left this page blank on an expired session under App Service + // EasyAuth: both /.auth/me and /api/me 302 to the cross-origin AAD login, the + // browser follows the redirect, and axios reports the CORS-blocked result as a + // network error — never a success, so neither probe was ever "successful". The + // session-ended prompt needs no data, so an errored probe is still enough to show it. + const authProbed = + orgData.isSuccess || orgData.isError || swaStatus.isSuccess || swaStatus.isError + const sessionProps = { title: 'Sign in to CIPP', // reading localStorage during render is safe here: the gate below keeps this @@ -203,7 +212,7 @@ const Page = ({ reason = 'session' }) => { {/* If an impersonated role can't load /me, this page is what renders — the exit affordance must exist here or the user is stuck until they clear localStorage. */} - {(orgData.isSuccess || swaStatus.isSuccess) && Array.isArray(userRoles) && ( + {authProbed && Array.isArray(userRoles) && (