diff --git a/.github/workflows/cipp_dev_build.yml b/.github/workflows/cipp_dev_build.yml index 52758378212b..8f9def9f61b8 100644 --- a/.github/workflows/cipp_dev_build.yml +++ b/.github/workflows/cipp_dev_build.yml @@ -54,7 +54,7 @@ jobs: # Upload to Azure Blob Storage - name: Azure Blob Upload - uses: LanceMcCarthy/Action-AzureBlobUpload@v3.8.0 + uses: LanceMcCarthy/Action-AzureBlobUpload@v3.9.0 with: connection_string: ${{ secrets.AZURE_CONNECTION_STRING }} container_name: cipp diff --git a/.github/workflows/cipp_frontend_build.yml b/.github/workflows/cipp_frontend_build.yml index bf86646e19cd..900a1adb0992 100644 --- a/.github/workflows/cipp_frontend_build.yml +++ b/.github/workflows/cipp_frontend_build.yml @@ -47,7 +47,7 @@ jobs: # Upload to Azure Blob Storage - name: Azure Blob Upload - uses: LanceMcCarthy/Action-AzureBlobUpload@v3.8.0 + uses: LanceMcCarthy/Action-AzureBlobUpload@v3.9.0 with: connection_string: ${{ secrets.AZURE_CONNECTION_STRING }} container_name: cipp diff --git a/package.json b/package.json index 0b146367a28c..295eea0297e5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cipp", - "version": "10.2.2", + "version": "10.2.4", "author": "CIPP Contributors", "homepage": "https://cipp.app/", "bugs": { @@ -51,7 +51,7 @@ "@tiptap/extension-table": "^3.19.0", "@tiptap/pm": "^3.4.1", "@tiptap/react": "^3.4.1", - "@tiptap/starter-kit": "^3.20.0", + "@tiptap/starter-kit": "^3.20.1", "@uiw/react-json-view": "^2.0.0-alpha.41", "@vvo/tzdb": "^6.198.0", "apexcharts": "5.3.5", @@ -61,7 +61,7 @@ "export-to-csv": "^1.3.0", "formik": "2.4.9", "gray-matter": "4.0.3", - "i18next": "25.8.13", + "i18next": "25.8.18", "javascript-time-ago": "^2.6.2", "jspdf": "^4.2.0", "jspdf-autotable": "^5.0.7", @@ -98,7 +98,7 @@ "react-redux": "9.2.0", "react-syntax-highlighter": "^16.1.0", "react-time-ago": "^7.3.3", - "react-virtuoso": "^4.18.1", + "react-virtuoso": "^4.18.3", "react-window": "^2.2.5", "recharts": "^3.7.0", "redux": "5.0.1", @@ -110,7 +110,7 @@ "simplebar": "6.3.3", "simplebar-react": "3.3.2", "stylis-plugin-rtl": "2.1.1", - "typescript": "5.9.2", + "typescript": "5.9.3", "yup": "1.7.1" }, "devDependencies": { diff --git a/public/version.json b/public/version.json index 2550102c4f0b..0e0e9967ccb6 100644 --- a/public/version.json +++ b/public/version.json @@ -1,3 +1,3 @@ { - "version": "10.2.2" + "version": "10.2.4" } \ No newline at end of file diff --git a/src/components/CippComponents/CippApiDialog.jsx b/src/components/CippComponents/CippApiDialog.jsx index 4ba2060a9410..da48eb7e4ccd 100644 --- a/src/components/CippComponents/CippApiDialog.jsx +++ b/src/components/CippComponents/CippApiDialog.jsx @@ -52,7 +52,7 @@ export const CippApiDialog = (props) => { useEffect(() => { if (createDialog.open) { setIsFormSubmitted(false); - formHook.reset(defaultvalues || {}); + formHook.reset(typeof defaultvalues === "function" ? defaultvalues(row) : defaultvalues || {}); } }, [createDialog.open, defaultvalues]); diff --git a/src/components/CippComponents/CippAutopilotProfileDrawer.jsx b/src/components/CippComponents/CippAutopilotProfileDrawer.jsx index 7edc95b46b60..fa1ac63baa52 100644 --- a/src/components/CippComponents/CippAutopilotProfileDrawer.jsx +++ b/src/components/CippComponents/CippAutopilotProfileDrawer.jsx @@ -109,8 +109,8 @@ export const CippAutopilotProfileDrawer = ({ {createProfile.isLoading ? "Creating..." : createProfile.isSuccess - ? "Create Another" - : "Create Profile"} + ? "Create Another" + : "Create Profile"} - - ); - }; - return ( } + CardButton={ + + + + + } > Configure how long to keep backup files. Both CIPP system backups and tenant backups will be diff --git a/src/components/CippSettings/CippGDAP/CippFlowDiagram.jsx b/src/components/CippSettings/CippGDAP/CippFlowDiagram.jsx new file mode 100644 index 000000000000..a6a2e7ada925 --- /dev/null +++ b/src/components/CippSettings/CippGDAP/CippFlowDiagram.jsx @@ -0,0 +1,115 @@ +import React from "react"; +import { Box, Typography, Chip, Stack, Paper } from "@mui/material"; +import { ArrowForward, ArrowDownward } from "@mui/icons-material"; + +/** + * Generic Flow Diagram Component + * Displays a horizontal or vertical flow of connected nodes + * + * @param {Array} nodes - Array of node objects with { id, label, icon, color, variant, details } + * @param {string} direction - 'horizontal' or 'vertical' + * @param {boolean} showArrows - Whether to show connecting arrows + * @param {object} nodeSx - Custom styles for nodes + */ +export const CippFlowDiagram = ({ + nodes = [], + direction = "horizontal", + showArrows = true, + nodeSx = {}, + ...other +}) => { + if (!nodes || nodes.length === 0) return null; + + const isHorizontal = direction === "horizontal"; + const ArrowIcon = isHorizontal ? ArrowForward : ArrowDownward; + + return ( + + {nodes.map((node, index) => ( + + 2 ? 2.5 : 2, + minWidth: node.elevation && node.elevation > 2 ? 180 : 150, + maxWidth: node.elevation && node.elevation > 2 ? 280 : 250, + width: isHorizontal ? "auto" : "100%", + textAlign: "center", + backgroundColor: node.backgroundColor || node.color || "background.paper", + border: + node.borderWidth + ? `${node.borderWidth}px solid ${node.borderColor || "primary.main"}` + : (node.variant === "outlined" || node.borderColor + ? `2px solid ${node.borderColor || "primary.main"}` + : 0), + borderColor: node.borderColor || (node.variant === "outlined" ? "primary.main" : "transparent"), + position: "relative", + flexShrink: 0, + transition: "all 0.2s ease-in-out", + ...nodeSx, + }} + > + {node.icon && ( + + {node.icon} + + )} + + {node.label} + + {node.subLabel && ( + + {node.subLabel} + + )} + {node.chips && node.chips.length > 0 && ( + + {node.chips.map((chip, chipIndex) => ( + + ))} + + )} + {node.details && ( + + {typeof node.details === "string" ? ( + + {node.details} + + ) : ( + node.details + )} + + )} + + {showArrows && index < nodes.length - 1 && ( + + + + )} + + ))} + + ); +}; diff --git a/src/components/CippSettings/CippGDAP/CippGDAPTrace.jsx b/src/components/CippSettings/CippGDAP/CippGDAPTrace.jsx new file mode 100644 index 000000000000..4cea7e5c94b4 --- /dev/null +++ b/src/components/CippSettings/CippGDAP/CippGDAPTrace.jsx @@ -0,0 +1,55 @@ +import React, { forwardRef, useImperativeHandle, useRef, useMemo } from "react"; +import { Security } from "@mui/icons-material"; +import { useDialog } from "../../../hooks/use-dialog"; +import { CippGDAPTraceDialog } from "./CippGDAPTraceDialog"; + +/** + * Trace GDAP dialog: render this and pass a ref; call ref.current.open(row) to open for a tenant row. + */ +export const CippGDAPTrace = forwardRef(function CippGDAPTrace(_, ref) { + const gdapTraceDialog = useDialog(); + const [gdapTraceRow, setGdapTraceRow] = React.useState(null); + + useImperativeHandle(ref, () => ({ + open(row) { + setGdapTraceRow(row); + gdapTraceDialog.handleOpen(); + }, + })); + + const handleClose = () => { + setGdapTraceRow(null); + }; + + return ( + gdapTraceRow && ( + + ) + ); +}); + +/** + * Hook for using Trace GDAP on a tenants table. + * Returns ref (pass to ), traceGdapAction (add to table actions), and CippGDAPTrace. + */ +export function useCippGDAPTrace() { + const ref = useRef(null); + const traceGdapAction = useMemo( + () => ({ + label: "Trace GDAP", + icon: , + noConfirm: true, + customFunction: (row) => ref.current?.open(row), + condition: (row) => row.displayName !== "*Partner Tenant", + }), + [] + ); + return { ref, traceGdapAction, CippGDAPTrace }; +} + +export default CippGDAPTrace; diff --git a/src/components/CippSettings/CippGDAP/CippGDAPTraceDialog.jsx b/src/components/CippSettings/CippGDAP/CippGDAPTraceDialog.jsx new file mode 100644 index 000000000000..de69ba3ea0a6 --- /dev/null +++ b/src/components/CippSettings/CippGDAP/CippGDAPTraceDialog.jsx @@ -0,0 +1,124 @@ +import React, { useState, useEffect } from "react"; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Stack, + Box, + Alert, +} from "@mui/material"; +import { useForm } from "react-hook-form"; +import { ApiGetCall } from "../../../api/ApiCall"; +import CippFormComponent from "../../CippComponents/CippFormComponent"; +import { CippGDAPTraceResults } from "./CippGDAPTraceResults"; + +export const CippGDAPTraceDialog = ({ createDialog, row, title = "Trace GDAP Access", onClose }) => { + const formHook = useForm({ + defaultValues: { + UPN: "", + }, + mode: "onChange", + }); + + const [apiRequest, setApiRequest] = useState({ + url: "", + waiting: false, + queryKey: null, + data: {}, + }); + + const tenantFilter = row?.customerId || row?.defaultDomainName || ""; + + const apiCall = ApiGetCall({ + url: apiRequest.url, + queryKey: apiRequest.queryKey, + enabled: apiRequest.waiting && !!apiRequest.url, + refetchOnMount: false, + refetchOnReconnect: false, + }); + + useEffect(() => { + if (createDialog.open) { + formHook.reset({ UPN: "" }); + setApiRequest({ + url: "", + waiting: false, + queryKey: null, + data: {}, + }); + } + }, [createDialog.open]); + + const onSubmit = (data) => { + const url = `/api/ExecGDAPTrace?TenantFilter=${encodeURIComponent(tenantFilter)}&UPN=${encodeURIComponent(data.UPN)}`; + setApiRequest({ + url: url, + waiting: true, + queryKey: Date.now(), + data: data, + }); + }; + + const handleClose = () => { + createDialog.handleClose(); + formHook.reset(); + setApiRequest({ + url: "", + waiting: false, + queryKey: null, + data: {}, + }); + if (onClose) { + onClose(); + } + }; + + const isLoading = apiCall.isFetching || apiCall.isPending; + const error = apiCall.isError ? apiCall.error?.response?.data?.Error || apiCall.error?.message : null; + const data = apiCall.isSuccess ? (apiCall.data?.data || apiCall.data) : null; + + return ( + +
+ {title} + + + + Beta Feature: This GDAP access tracing feature is currently in beta and may not + account for all scenarios. Results should be used as a reference and verified through other + methods when making critical access decisions. + + + + + + + + + + + + + +
+
+ ); +}; diff --git a/src/components/CippSettings/CippGDAP/CippGDAPTraceResults.jsx b/src/components/CippSettings/CippGDAP/CippGDAPTraceResults.jsx new file mode 100644 index 000000000000..ff34b407388a --- /dev/null +++ b/src/components/CippSettings/CippGDAP/CippGDAPTraceResults.jsx @@ -0,0 +1,525 @@ +import React, { useState } from "react"; +import { + Box, + Card, + CardContent, + Typography, + Accordion, + AccordionSummary, + AccordionDetails, + Chip, + Stack, + Divider, + Alert, + Grid, + LinearProgress, + Tooltip, + IconButton, +} from "@mui/material"; +import { + ExpandMore, + CheckCircle, + Cancel, + Warning, + Security, + Group, + AccountTree, + InfoOutlined, +} from "@mui/icons-material"; +import { CippCodeBlock } from "../../CippComponents/CippCodeBlock"; +import { CippPathVisualization } from "./CippPathVisualization"; +import { getCippRoleTranslation } from "../../../utils/get-cipp-role-translation"; + +export const CippGDAPTraceResults = ({ data, isLoading, error }) => { + const [expandedRoles, setExpandedRoles] = useState({}); + const [expandedRelationships, setExpandedRelationships] = useState({}); + + if (isLoading) { + return ( + + + + Tracing GDAP access path... + + + ); + } + + if (error) { + return ( + + Error + {error} + + ); + } + + if (!data) { + return ( + + No data available. Please run the trace first. + + ); + } + + const handleRoleExpand = (roleId) => { + setExpandedRoles((prev) => ({ + ...prev, + [roleId]: !prev[roleId], + })); + }; + + const handleRelationshipExpand = (relationshipId) => { + setExpandedRelationships((prev) => ({ + ...prev, + [relationshipId]: !prev[relationshipId], + })); + }; + + const { tenantName, userUPN, userDisplayName, roles, relationships, summary, error: dataError } = data; + + if (dataError) { + return ( + + Trace completed with issues + {dataError} + + ); + } + + const getRoleStatusChip = (role) => { + if (role.isUserHasAccess) { + return } label="Has Access" color="success" size="small" />; + } else if (role.isAssigned) { + return } label="Assigned but No Access" color="warning" size="small" />; + } else if (role.roleExistsInRelationship) { + return } label="In Relationship but Not Assigned" color="info" size="small" />; + } else { + return } label="Not In Any Relationship" color="default" size="small" />; + } + }; + + const renderMembershipPath = (path) => { + if (!path || path.length === 0) return null; + + const sortedPath = [...path].sort((a, b) => { + if (a.sequence !== undefined && b.sequence !== undefined) { + return a.sequence - b.sequence; + } + return 0; + }); + + const hasMultipleGroups = sortedPath.length > 1; + + return ( + + {sortedPath.map((step, index) => ( + + + {hasMultipleGroups && step.sequence !== undefined && ( + + )} + {step.membershipType === "direct" && ( + } + label="Direct" + color="success" + size="small" + variant="outlined" + /> + )} + {step.membershipType === "nested" && ( + } + label="Nested" + color="info" + size="small" + variant="outlined" + /> + )} + {step.membershipType === "not_member" && ( + } + label="Not Member" + color="error" + size="small" + variant="outlined" + /> + )} + + {step.groupName || step.groupId} + + + {index < sortedPath.length - 1 && ( + + + ↓ + + + )} + + ))} + + ); + }; + + return ( + + {/* Summary Section */} + + + + Trace Summary + + + + + + Tenant + + + {tenantName} + + + + + User + + + {userDisplayName || userUPN} + + + + + Total Relationships + + + {summary?.totalRelationships || 0} + + + + + Roles with Access + + + {summary?.rolesWithAccess || 0} / {summary?.totalRoles || 15} + + + + + + Roles Assigned but No Access + + + + + + + + + {summary?.rolesAssignedButNoAccess || 0} + + + + + + Roles In Relationship but Not Assigned + + + + + + + + + {summary?.rolesInRelationshipButNotAssigned || 0} + + + + + + Roles Not In Any Relationship + + + + + + + + + {summary?.rolesNotInAnyRelationship || 0} + + + + + + + {/* Roles Section */} + + + + + GDAP Roles Access + + + + {roles && roles.length > 0 ? ( + roles.map((role) => ( + handleRoleExpand(role.roleId)} + > + }> + + {getRoleStatusChip(role)} + + + {role.roleName} + + {role.roleDescription && ( + + {role.roleDescription} + + )} + + + + + + {role.isUserHasAccess && role.accessPaths && role.accessPaths.length > 0 ? ( + <> + + Access Paths ({role.accessPaths.length}): + + {role.accessPaths.map((path, pathIndex) => ( + + + + ))} + + ) : role.isAssigned ? ( + <> + + Role is assigned but user does not have access through any group. + + {role.relationshipsWithRole && role.relationshipsWithRole.length > 0 && ( + <> + + Assigned Groups ({role.relationshipsWithRole.length}): + + {role.relationshipsWithRole.map((rel, relIndex) => ( + + + + ))} + + )} + + ) : role.roleExistsInRelationship ? ( + <> + + This role exists in at least one GDAP relationship but is not assigned to any groups. + + {role.relationshipsWithRoleAvailable && role.relationshipsWithRoleAvailable.length > 0 && ( + <> + Available in relationships: + {role.relationshipsWithRoleAvailable.map((rel, relIndex) => ( + + + • {rel.relationshipName} ({rel.relationshipStatus}) + + + ))} + + )} + + ) : ( + This role is not available in any GDAP relationship. + )} + + {role.relationshipsWithRole && role.relationshipsWithRole.length > 0 && ( + + + All relationships with this role: {role.relationshipsWithRole.length} + + + )} + + + + )) + ) : ( + No roles found. + )} + + + + + {/* Relationships Section */} + {relationships && relationships.length > 0 && ( + + + + + GDAP Relationships + + + + {relationships.map((relationship) => ( + handleRelationshipExpand(relationship.relationshipId)} + > + }> + + + + {relationship.relationshipName} + + + {relationship.groups?.length || 0} groups + + + + + + + + Customer Tenant: {relationship.customerTenantName || relationship.customerTenantId} + + + {relationship.groups && relationship.groups.length > 0 ? ( + <> + + Groups ({relationship.groups.length}): + + {relationship.groups.map((group, groupIndex) => { + const groupRole = roles?.find((r) => + r.relationshipsWithRole?.some((rel) => rel.groupId === group.groupId) + ); + const firstRoleDef = group.roles?.[0]; + const roleName = + groupRole?.roleName || + firstRoleDef?.displayName || + (firstRoleDef?.roleDefinitionId + ? getCippRoleTranslation(firstRoleDef.roleDefinitionId) + : null) || + "Role"; + + return ( + + + {group.roles && group.roles.length > 1 && ( + + + Additional Roles: + + + {group.roles.slice(1).map((role, roleIndex) => ( + + ))} + + + )} + + ); + })} + + ) : ( + No groups found in this relationship. + )} + + + + ))} + + + + )} + + {/* Raw JSON View (Collapsible) */} + + + + }> + View Raw JSON + + + + + + + + + ); +}; diff --git a/src/components/CippSettings/CippGDAP/CippPathVisualization.jsx b/src/components/CippSettings/CippGDAP/CippPathVisualization.jsx new file mode 100644 index 000000000000..0dbdbb16572a --- /dev/null +++ b/src/components/CippSettings/CippGDAP/CippPathVisualization.jsx @@ -0,0 +1,266 @@ +import React from "react"; +import { Box, Typography, Chip, Stack, Paper } from "@mui/material"; +import { + Person, + Group, + Security, + AccountTree, + CheckCircle, + Cancel, + Warning, +} from "@mui/icons-material"; +import { CippFlowDiagram } from "./CippFlowDiagram"; + +/** + * Visual Path Component for GDAP Access Traces + * Shows a visual representation of the access path from User → Groups → Role + */ +export const CippPathVisualization = ({ + userDisplayName, + userUPN, + membershipPath = [], + groupName, + roleName, + relationshipName, + customerTenantName, + isMember = true, + ...other +}) => { + // Color scheme matching sankey diagrams + const colors = { + user: "hsl(28, 100%, 53%)", // Orange - enabled users + success: "hsl(99, 70%, 50%)", // Green - compliant, has access + error: "hsl(0, 100%, 50%)", // Red - errors, no access + info: "hsl(200, 70%, 50%)", // Blue - nested groups, info + warning: "hsl(39, 100%, 50%)", // Yellow/Orange - warnings + teal: "hsl(140, 70%, 50%)", // Teal - security defaults + grey: "hsl(0, 0%, 60%)", // Grey - disabled + }; + + if (!membershipPath || membershipPath.length === 0) { + // Fallback: show simple path even without detailed membership path + const nodes = [ + { + id: "user", + label: userDisplayName || userUPN, + subLabel: "User", + icon: , + backgroundColor: `${colors.user}20`, // 20% opacity + borderColor: colors.user, + chips: [], + }, + { + id: "group", + label: groupName || "Unknown Group", + subLabel: "Security Group", + icon: isMember ? : , + backgroundColor: isMember ? `${colors.success}20` : `${colors.error}20`, + borderColor: isMember ? colors.success : colors.error, + chips: [ + { + label: isMember ? "Member" : "Not Member", + sx: { backgroundColor: isMember ? colors.success : colors.error, color: "white" }, + size: "small", + }, + ], + }, + { + id: "role", + label: roleName || "Role", + subLabel: "GDAP Role", + icon: , + backgroundColor: isMember ? `${colors.success}20` : `${colors.grey}20`, + borderColor: isMember ? colors.success : colors.grey, + chips: [ + { + label: isMember ? "Has Access" : "No Access", + sx: { backgroundColor: isMember ? colors.success : colors.grey, color: "white" }, + size: "small", + }, + ], + }, + ]; + + return ( + + {relationshipName && ( + + Relationship: {relationshipName} + {customerTenantName && ` → ${customerTenantName}`} + + )} + + + ); + } + + // Sort path by sequence if available + const sortedPath = [...membershipPath].sort((a, b) => { + if (a.sequence !== undefined && b.sequence !== undefined) { + return a.sequence - b.sequence; + } + return 0; + }); + + // Build nodes for the flow diagram + const nodes = []; + + // Start with user node + nodes.push({ + id: "user", + label: userDisplayName || userUPN, + subLabel: "User", + icon: , + backgroundColor: `${colors.user}20`, + borderColor: colors.user, + chips: [], + }); + + // Add group nodes from the path + sortedPath.forEach((step, index) => { + const isFirstGroup = index === 0; + const isLastGroup = index === sortedPath.length - 1; + const isDirect = step.membershipType === "direct"; + const isNested = step.membershipType === "nested"; + const isNotMember = step.membershipType === "not_member"; + const isIntermediate = !isFirstGroup && !isLastGroup; + + const chips = []; + + if (isNotMember) { + chips.push({ + label: "Not Member", + sx: { backgroundColor: colors.error, color: "white" }, + icon: , + }); + } else if (isDirect) { + chips.push({ + label: "Direct", + sx: { backgroundColor: colors.success, color: "white" }, + icon: , + }); + } else if (isNested) { + if (isLastGroup && sortedPath.length === 1) { + // no chip + } else { + chips.push({ + label: "Nested", + sx: { backgroundColor: colors.info, color: "white" }, + icon: , + }); + } + } + + if (sortedPath.length > 1 && step.sequence !== undefined) { + chips.push({ + label: `Step ${step.sequence + 1}`, + variant: "outlined", + size: "small", + sx: { borderColor: colors.info, color: colors.info, fontWeight: "bold" }, + }); + } + + if (isLastGroup && !isNotMember) { + chips.push({ + label: "GDAP Mapped", + sx: { backgroundColor: colors.teal, color: "white", fontWeight: "bold" }, + size: "small", + }); + } + + let groupColor; + let subLabel; + let nodeElevation = 2; + let nodeBorderWidth = 0; + + if (isNotMember) { + groupColor = colors.error; + subLabel = "Target Group (No Access)"; + } else if (isLastGroup) { + groupColor = colors.teal; + if (isDirect) { + subLabel = "GDAP Mapped Group (Direct)"; + } else if (isNested) { + subLabel = + sortedPath.length === 1 ? "GDAP Mapped Group (User Nested)" : "GDAP Mapped Group (Nested)"; + } else { + subLabel = "GDAP Mapped Group"; + } + nodeElevation = 4; + nodeBorderWidth = 3; + } else if (isFirstGroup && isDirect) { + groupColor = colors.success; + subLabel = "User's Direct Group"; + } else if (isFirstGroup && isNested) { + groupColor = colors.info; + subLabel = "User's Group (via nesting)"; + } else if (isIntermediate) { + groupColor = colors.info; + subLabel = "Intermediate Group"; + nodeElevation = 1; + } else { + groupColor = colors.info; + subLabel = "Group"; + } + + nodes.push({ + id: `group-${step.groupId || index}`, + label: step.groupName || step.groupId || "Unknown Group", + subLabel: subLabel, + icon: + isNotMember ? ( + + ) : ( + + ), + backgroundColor: `${groupColor}${isLastGroup ? "30" : "20"}`, + borderColor: groupColor, + borderWidth: nodeBorderWidth, + elevation: nodeElevation, + chips: chips, + }); + }); + + const hasAccess = !sortedPath.some((step) => step.membershipType === "not_member"); + const roleColor = hasAccess ? colors.success : colors.grey; + nodes.push({ + id: "role", + label: roleName || "Role", + subLabel: "GDAP Role", + icon: , + backgroundColor: `${roleColor}20`, + borderColor: roleColor, + chips: [ + { + label: hasAccess ? "Has Access" : "No Access", + sx: { backgroundColor: roleColor, color: "white" }, + icon: hasAccess ? : , + }, + ], + }); + + return ( + + {relationshipName && ( + + + + Relationship: {relationshipName} + + {customerTenantName && ( + <> + + → + + + Customer: {customerTenantName} + + + )} + + + )} + + + ); +}; diff --git a/src/components/CippSettings/CippLogRetentionSettings.jsx b/src/components/CippSettings/CippLogRetentionSettings.jsx index a45b0c45bea3..3f949ed0c57b 100644 --- a/src/components/CippSettings/CippLogRetentionSettings.jsx +++ b/src/components/CippSettings/CippLogRetentionSettings.jsx @@ -61,40 +61,36 @@ const CippLogRetentionSettings = () => { } }; - const RetentionControls = () => { - return ( - - - - - ); - }; - return ( } + CardButton={ + + + + + } > Configure how long to keep CIPP log entries. Logs will be automatically deleted after this diff --git a/src/components/CippSettings/CippPasswordSettings.jsx b/src/components/CippSettings/CippPasswordSettings.jsx index 1394beff3fe5..56a4845cbb28 100644 --- a/src/components/CippSettings/CippPasswordSettings.jsx +++ b/src/components/CippSettings/CippPasswordSettings.jsx @@ -1,79 +1,98 @@ -import { Button, ButtonGroup, SvgIcon, Typography } from "@mui/material"; +import { Button, Chip, SvgIcon, Tooltip, Typography } from "@mui/material"; import CippButtonCard from "../CippCards/CippButtonCard"; -import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; -import { KeyIcon } from "@heroicons/react/24/outline"; +import { useRouter } from "next/router"; +import { Cog6ToothIcon } from "@heroicons/react/24/outline"; +import { ApiGetCall } from "../../api/ApiCall"; + +// Password configuration constants +const PASSWORD_TYPES = { + CLASSIC: 'Classic', + PASSPHRASE: 'Passphrase' +}; + +const DEFAULT_VALUES = { + CHAR_COUNT: 14, + WORD_COUNT: 4, + SPECIAL_CHAR_SET: '$%&*#', + SEPARATOR: '-' +}; const CippPasswordSettings = () => { + const router = useRouter(); const passwordSetting = ApiGetCall({ url: "/api/ExecPasswordConfig?list=true", queryKey: "PasswordSettings", }); - const passwordChange = ApiPostCall({ - datafromUrl: true, - relatedQueryKeys: "PasswordSettings", - }); + // Validate API response structure and handle loading/error states + const isValidResponse = passwordSetting.data && + passwordSetting.data.Results && + typeof passwordSetting.data.Results === 'object' && + Object.prototype.hasOwnProperty.call(passwordSetting.data.Results, 'passwordType'); + + const isLoading = passwordSetting.isLoading; + const hasError = passwordSetting.isError || (!isLoading && !isValidResponse); + + // Use defaults when data is not available + const r = isValidResponse ? passwordSetting.data.Results : null; + const isClassic = !r || r?.passwordType === PASSWORD_TYPES.CLASSIC; - const handlePasswordTypeChange = (type) => { - passwordChange.mutate({ - url: "/api/ExecPasswordConfig", - data: { passwordType: type }, - queryKey: "PasswordSettingsPost", - }); + const currentLabel = isClassic + ? `Classic — ${r?.charCount || DEFAULT_VALUES.CHAR_COUNT} characters` + : `Passphrase — ${r?.wordCount || DEFAULT_VALUES.WORD_COUNT} words`; + + const getErrorMessage = () => { + if (passwordSetting.isError) { + return "Network error loading settings. Click Configure to update."; + } + if (!isLoading && !isValidResponse) { + return "Invalid server response. Click Configure to update settings."; + } + return ""; }; - const PasswordTypeButtons = () => { - const passwordTypes = ["Classic", "Correct-Battery-Horse"]; - return passwordTypes.map((type) => ( - - )); + const handleConfigureClick = () => { + router.push("/cipp/settings/password-config"); }; + return ( - - - - - + } + onClick={handleConfigureClick} + > + Configure + + } > - Choose your password style. Classic passwords are a combination of letters and symbols. - Correct-Battery-Horse style is a passphrase, which is easier to remember and more secure - than classic passwords. + Configure password generation settings including type, length, character sets, and + passphrase options. + + {hasError && !isLoading && ( + + {getErrorMessage()} + + )} ); }; diff --git a/src/components/CippStandards/CippStandardDialog.jsx b/src/components/CippStandards/CippStandardDialog.jsx index ffe959653259..6ebe6362930c 100644 --- a/src/components/CippStandards/CippStandardDialog.jsx +++ b/src/components/CippStandards/CippStandardDialog.jsx @@ -83,7 +83,7 @@ const StandardCard = memo( observer.disconnect(); } }, - { threshold: 0.1 } + { threshold: 0.1 }, ); const currentRef = document.getElementById(`standard-card-${standard.name}`); @@ -233,8 +233,8 @@ const StandardCard = memo( standard.impact === "High Impact" ? "error" : standard.impact === "Medium Impact" - ? "warning" - : "info" + ? "warning" + : "info" } /> {expanded && standard.recommendedBy?.length > 0 && ( @@ -334,7 +334,7 @@ const StandardCard = memo( // If we get here, nothing important changed, skip re-render return true; - } + }, ); StandardCard.displayName = "StandardCard"; @@ -342,7 +342,7 @@ StandardCard.displayName = "StandardCard"; // Virtualized grid to handle large numbers of standards efficiently const VirtualizedStandardGrid = memo(({ items, renderItem }) => { const [itemsPerRow, setItemsPerRow] = useState(() => - window.innerWidth > 960 ? 4 : window.innerWidth > 600 ? 2 : 1 + window.innerWidth > 960 ? 4 : window.innerWidth > 600 ? 2 : 1, ); // Handle window resize for responsive grid @@ -470,8 +470,8 @@ const CompactStandardList = memo( standard.impact === "High Impact" ? "error" : standard.impact === "Medium Impact" - ? "warning" - : "info" + ? "warning" + : "info" } /> @@ -618,7 +618,7 @@ const CompactStandardList = memo( })} ); - } + }, ); CompactStandardList.displayName = "CompactStandardList"; @@ -851,7 +851,7 @@ const CippStandardDialog = ({ showOnlyNew, statusFilter, selectedStandards, - ] + ], ); // Enhanced sort function @@ -899,7 +899,7 @@ const CippStandardDialog = ({ return 0; }); }, - [sortBy, sortOrder] + [sortBy, sortOrder], ); // Optimize handleAddClick to be more performant @@ -914,7 +914,7 @@ const CippStandardDialog = ({ }, 100); }); }, - [handleAddMultipleStandard] + [handleAddMultipleStandard], ); // Optimize search debounce with a higher timeout for better performance @@ -922,7 +922,7 @@ const CippStandardDialog = ({ debounce((query) => { setSearchQuery(query.trim()); }, 350), // Increased debounce time for better performance - [setSearchQuery] + [setSearchQuery], ); // Only process visible categories on demand to improve performance @@ -935,7 +935,7 @@ const CippStandardDialog = ({ setLocalSearchQuery(value); handleSearchQueryChange(value); }, - [handleSearchQueryChange] + [handleSearchQueryChange], ); // Clear all filters @@ -992,7 +992,7 @@ const CippStandardDialog = ({ (standard) => { const item = allItems.find((item) => item.standard.name === standard.name); return item; - } + }, ); setProcessedItems(sortedAllItems); @@ -1028,7 +1028,7 @@ const CippStandardDialog = ({ isButtonDisabled={isButtonDisabled} /> ), - [selectedStandards, handleToggleSingleStandard, handleAddClick, isButtonDisabled] + [selectedStandards, handleToggleSingleStandard, handleAddClick, isButtonDisabled], ); // Count active filters @@ -1047,6 +1047,7 @@ const CippStandardDialog = ({ onClose={handleClose} maxWidth="xxl" fullWidth + fullScreen keepMounted={false} TransitionProps={{ onExited: () => { @@ -1056,15 +1057,12 @@ const CippStandardDialog = ({ }} PaperProps={{ sx: { - minWidth: "720px", - maxHeight: "90vh", - height: "90vh", display: "flex", flexDirection: "column", }, }} > - Select a Standard to Add + Select a Standard to Add { setSavedItem(id); @@ -271,289 +285,308 @@ const CippStandardsSideBar = ({ }; return ( - - - - - - {isDriftMode ? "About Drift Templates" : "About Standard Templates"} - - {isDriftMode ? ( - - - Drift templates provide continuous monitoring of tenant configurations to detect - unauthorized changes. Each tenant can only have one drift template applied at a time. - - - Remediation Options: - - - • Automatic Remediation: Immediately reverts unauthorized changes - back to the template configuration -
Manual Remediation: Sends email notifications for review, - allowing you to accept or deny detected changes -
- - Key Features: - - - • Monitors all security standards, Conditional Access policies, and Intune policies -
- • Detects changes made outside of CIPP -
- • Configurable webhook and email notifications -
• Granular control over deviation acceptance -
-
- ) : ( - - - Standard templates can be applied to multiple tenants and allow overlapping - configurations with intelligent merging based on specificity and timing. - - - - Merge Priority (Specificity): - - - 1. Individual Tenant - Highest priority, overrides all others -
- 2. Tenant Group - Overrides "All Tenants" settings -
- 3. All Tenants - Lowest priority, default baseline -
- - - Conflict Resolution: - - - When multiple standards target the same scope (e.g., two tenant-specific templates), - the most recently created template takes precedence. - - - - Example: An "All Tenants" template enables audit log retention for 90 - days, but you need 365 days for one specific tenant. Create a tenant-specific template - with 365-day retention - it will override the global setting for that tenant only. - -
- )} - - - {/* Hidden field to mark drift templates */} - {isDriftMode && ( - - )} - - - - - - - {/* Show drift error */} - {isDriftMode && driftError && {driftError}} - - {(watchForm.tenantFilter?.some( - (tenant) => tenant.value === "AllTenants" || tenant.type === "Group", - ) || - (watchForm.excludedTenants && watchForm.excludedTenants.length > 0)) && ( - <> - - - - )} - {/* Drift-specific fields */} - {isDriftMode && ( - <> - - - - - - When enabled, all drift alert notifications (email, webhook, and PSA) will be - disabled. + <> + setAboutOpen(false)} + size="sm" + > + + {isDriftMode ? ( + + + Drift templates provide continuous monitoring of tenant configurations to detect + unauthorized changes. Each tenant can only have one drift template applied at a + time. + + + Remediation Options: + + + • Automatic Remediation: Immediately reverts unauthorized changes + back to the template configuration +
Manual Remediation: Sends email notifications for review, + allowing you to accept or deny detected changes +
+ + Key Features: - + + • Monitors all security standards, Conditional Access policies, and Intune policies +
+ • Detects changes made outside of CIPP +
+ • Configurable webhook and email notifications +
• Granular control over deviation acceptance +
+
+ ) : ( + + + Standard templates can be applied to multiple tenants and allow overlapping + configurations with intelligent merging based on specificity and timing. + + + Merge Priority (Specificity): + + + 1. Individual Tenant - Highest priority, overrides all others +
+ 2. Tenant Group - Overrides "All Tenants" settings +
+ 3. All Tenants - Lowest priority, default baseline +
+ + Conflict Resolution: + + + When multiple standards target the same scope (e.g., two tenant-specific templates), + the most recently created template takes precedence. + + + Example: An "All Tenants" template enables audit log retention for + 90 days, but you need 365 days for one specific tenant. Create a tenant-specific + template with 365-day retention - it will override the global setting for that + tenant only. + +
)} - {/* Hide schedule options in drift mode */} - {!isDriftMode && ( - <> - {updatedAt.date && ( - <> - - Last Updated by {updatedAt?.user} - - - )} +
+
+ + + setAboutOpen(true)} color="primary"> + + + + } + /> + + + + {/* Hidden field to mark drift templates */} + {isDriftMode && ( - + + + + + + {/* Show drift error */} + {isDriftMode && driftError && {driftError}} + + {(watchForm.tenantFilter?.some( + (tenant) => tenant.value === "AllTenants" || tenant.type === "Group", + ) || + (watchForm.excludedTenants && watchForm.excludedTenants.length > 0)) && ( + <> + + + + )} + {/* Drift-specific fields */} + {isDriftMode && ( + <> + + + + + + When enabled, all drift alert notifications (email, webhook, and PSA) will be + disabled. + + + )} + {/* Hide schedule options in drift mode */} + {!isDriftMode && ( + <> + {updatedAt.date && ( + <> + + Last Updated by {updatedAt?.user} + + + )} + + + This setting allows you to create this template and run it only by using "Run + Now". + + + )} + + + {/* Hide timeline/ticker in drift mode */} + {!isDriftMode && ( + <> + + + - This setting allows you to create this template and run it only by using "Run Now". -
- - )} -
-
- {/* Hide timeline/ticker in drift mode */} - {!isDriftMode && ( - <> - - - - {steps.map((step, index) => ( - - - - {index < steps.length - 1 && } - - {step} - - ))} - - - - )} - - - {actions.map((action, index) => ( - {action.icon}} - label={action.label} - onClick={action.handler} - disabled={ - !(watchForm.tenantFilter && watchForm.tenantFilter.length > 0) || - currentStep < 3 || - (isDriftMode && driftError) - } - /> - ))} - - - dialogAfterEffect(data.id)} - createDialog={createDialog} - title="Add Standard" - api={{ - confirmText: isDriftMode - ? "This template will run 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 12 hours.", - url: "/api/AddStandardsTemplate", - type: "POST", - replacementBehaviour: "removeNulls", - data: { - tenantFilter: "tenantFilter", - excludedTenants: "excludedTenants", - description: "description", - templateName: "templateName", - standards: "standards", - ...(edit ? { GUID: "GUID" } : {}), - ...(savedItem ? { GUID: savedItem } : {}), - runManually: isDriftMode ? false : "runManually", - isDriftTemplate: "isDriftTemplate", - ...(isDriftMode - ? { - type: "drift", - driftAlertWebhook: "driftAlertWebhook", - driftAlertEmail: "driftAlertEmail", - driftAlertDisableEmail: "driftAlertDisableEmail", - } - : {}), - }, - }} - row={formControl.getValues()} - formControl={formControl} - relatedQueryKeys={[ - "listStandardTemplates", - "listStandards", - `listStandardTemplates-${watchForm.GUID}`, - "ListTenantAlignment-drift-validation", - "ListTenantGroups-drift-validation", - ]} - /> -
+ {steps.map((step, index) => ( + + + + {index < steps.length - 1 && } + + {step} + + ))} + + + + )} + + + {actions.map((action, index) => ( + {action.icon}} + label={action.label} + onClick={action.handler} + disabled={ + !(watchForm.tenantFilter && watchForm.tenantFilter.length > 0) || + currentStep < 3 || + (isDriftMode && driftError) + } + /> + ))} + + + dialogAfterEffect(data.id)} + createDialog={createDialog} + title="Add Standard" + api={{ + confirmText: isDriftMode + ? "This template will run 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 12 hours.", + url: "/api/AddStandardsTemplate", + type: "POST", + replacementBehaviour: "removeNulls", + data: { + tenantFilter: "tenantFilter", + excludedTenants: "excludedTenants", + description: "description", + templateName: "templateName", + standards: "standards", + ...(edit ? { GUID: "GUID" } : {}), + ...(savedItem ? { GUID: savedItem } : {}), + runManually: isDriftMode ? false : "runManually", + isDriftTemplate: "isDriftTemplate", + ...(isDriftMode + ? { + type: "drift", + driftAlertWebhook: "driftAlertWebhook", + driftAlertEmail: "driftAlertEmail", + driftAlertDisableEmail: "driftAlertDisableEmail", + } + : {}), + }, + }} + row={formControl.getValues()} + formControl={formControl} + relatedQueryKeys={[ + "listStandardTemplates", + "listStandards", + `listStandardTemplates-${watchForm.GUID}`, + "ListTenantAlignment-drift-validation", + "ListTenantGroups-drift-validation", + ]} + /> + + ); }; diff --git a/src/components/CippWizard/CippWizardDialogContext.js b/src/components/CippWizard/CippWizardDialogContext.js new file mode 100644 index 000000000000..2ff78f19fe1b --- /dev/null +++ b/src/components/CippWizard/CippWizardDialogContext.js @@ -0,0 +1,10 @@ +import { createContext, useContext } from "react"; + +/** + * When CippWizardPage is used in dialogMode, it provides this context with a + * reference to the DialogActions DOM node. CippWizardStepButtons checks for + * it and portals its navigation buttons there, keeping the main content area + * clean while anchoring controls at the bottom of the dialog. + */ +export const CippWizardDialogContext = createContext(null); +export const useCippWizardDialog = () => useContext(CippWizardDialogContext); diff --git a/src/components/CippWizard/CippWizardGroupTemplates.jsx b/src/components/CippWizard/CippWizardGroupTemplates.jsx index 16e1bd61ef0d..2e1679808723 100644 --- a/src/components/CippWizard/CippWizardGroupTemplates.jsx +++ b/src/components/CippWizard/CippWizardGroupTemplates.jsx @@ -4,11 +4,12 @@ import CippFormComponent from "../CippComponents/CippFormComponent"; import { CippFormCondition } from "../CippComponents/CippFormCondition"; import { Grid } from "@mui/system"; import { useWatch } from "react-hook-form"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; export const CippWizardGroupTemplates = (props) => { const { postUrl, formControl, onPreviousStep, onNextStep, currentStep } = props; const watcher = useWatch({ control: formControl.control, name: "TemplateList" }); + const lastAppliedTemplate = useRef(null); const groupOptions = [ { label: "Dynamic Group", value: "dynamic" }, { label: "Dynamic Distribution Group", value: "dynamicDistribution" }, @@ -18,19 +19,28 @@ export const CippWizardGroupTemplates = (props) => { { label: "Mail Enabled Security Group", value: "security" }, ]; useEffect(() => { - if (watcher?.value) { + if (watcher?.value && watcher.value !== lastAppliedTemplate.current) { + lastAppliedTemplate.current = watcher.value; console.log("Loading template:", watcher); // Set groupType first to ensure conditional fields are visible - formControl.setValue("groupType", watcher.addedFields.groupType); + formControl.setValue("groupType", watcher.addedFields.groupType, { shouldValidate: true }); // Use setTimeout to ensure the DOM updates with the groupType before setting other fields setTimeout(() => { - formControl.setValue("displayName", watcher.addedFields.displayName); - formControl.setValue("description", watcher.addedFields.description); - formControl.setValue("username", watcher.addedFields.username); - formControl.setValue("allowExternal", watcher.addedFields.allowExternal); - formControl.setValue("membershipRules", watcher.addedFields.membershipRules); + formControl.setValue("displayName", watcher.addedFields.displayName, { + shouldValidate: true, + }); + formControl.setValue("description", watcher.addedFields.description, { + shouldValidate: true, + }); + formControl.setValue("username", watcher.addedFields.username, { shouldValidate: true }); + formControl.setValue("allowExternal", watcher.addedFields.allowExternal, { + shouldValidate: true, + }); + formControl.setValue("membershipRules", watcher.addedFields.membershipRules, { + shouldValidate: true, + }); console.log("Set membershipRules to:", watcher.addedFields.membershipRules); }, 100); diff --git a/src/components/CippWizard/CippWizardOffboarding.jsx b/src/components/CippWizard/CippWizardOffboarding.jsx index f407a962b628..34d8b37b2bf5 100644 --- a/src/components/CippWizard/CippWizardOffboarding.jsx +++ b/src/components/CippWizard/CippWizardOffboarding.jsx @@ -358,7 +358,7 @@ export const CippWizardOffboarding = (props) => { compareType="is" compareValue={true} > - + Scheduled Offboarding Date { fullWidth /> + - - Send results to: - - - + + Send results to: + + + { /> - + - - - - + + + diff --git a/src/components/CippWizard/CippWizardPage.jsx b/src/components/CippWizard/CippWizardPage.jsx index 6266a3ec1c4b..cda2b0cf5c58 100644 --- a/src/components/CippWizard/CippWizardPage.jsx +++ b/src/components/CippWizard/CippWizardPage.jsx @@ -1,8 +1,24 @@ -import { Box, Button, Container, Stack, SvgIcon } from "@mui/material"; +import { + Box, + Button, + Container, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + IconButton, + Stack, + SvgIcon, + useMediaQuery, +} from "@mui/material"; +import { Close } from "@mui/icons-material"; import { CippWizard } from "./CippWizard"; import { useRouter } from "next/router"; import { ArrowLeftIcon } from "@mui/x-date-pickers"; import { CippHead } from "../CippComponents/CippHead"; +import { CippWizardDialogContext } from "./CippWizardDialogContext"; +import { useState, useCallback } from "react"; const CippWizardPage = (props) => { const router = useRouter(); @@ -14,8 +30,62 @@ const CippWizardPage = (props) => { backButton = true, wizardOrientation = "horizontal", maxWidth = "xl", + dialogMode = false, + open = false, + onClose, + dialogIcon, + relatedQueryKeys, ...other } = props; + + const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md")); + const [actionsEl, setActionsEl] = useState(null); + const actionsRef = useCallback((el) => setActionsEl(el), []); + + const wizardNode = ( + + ); + + if (dialogMode) { + return ( + + + {dialogIcon} + {wizardTitle} + + + + + + + + {wizardNode} + + + + + + ); + } + return ( <> @@ -30,12 +100,9 @@ const CippWizardPage = (props) => { - + + {wizardNode} + diff --git a/src/components/CippWizard/CippWizardStepButtons.jsx b/src/components/CippWizard/CippWizardStepButtons.jsx index bf0e7f3a7918..64b8f0e23703 100644 --- a/src/components/CippWizard/CippWizardStepButtons.jsx +++ b/src/components/CippWizard/CippWizardStepButtons.jsx @@ -1,7 +1,9 @@ import { Button, Stack } from "@mui/material"; import { useFormState } from "react-hook-form"; +import { createPortal } from "react-dom"; import { ApiPostCall } from "../../api/ApiCall"; import { CippApiResults } from "../CippComponents/CippApiResults"; +import { useCippWizardDialog } from "./CippWizardDialogContext"; export const CippWizardStepButtons = (props) => { const { @@ -19,7 +21,18 @@ export const CippWizardStepButtons = (props) => { ...other } = props; const { isValid, isSubmitted, isSubmitting } = useFormState({ control: formControl.control }); - const sendForm = ApiPostCall({ relatedQueryKeys: queryKeys }); + const dialogContext = useCippWizardDialog(); + const mergedQueryKeys = [ + ...(Array.isArray(queryKeys) ? queryKeys : queryKeys ? [queryKeys] : []), + ...(Array.isArray(dialogContext?.relatedQueryKeys) + ? dialogContext.relatedQueryKeys + : dialogContext?.relatedQueryKeys + ? [dialogContext.relatedQueryKeys] + : []), + ]; + const sendForm = ApiPostCall({ + relatedQueryKeys: mergedQueryKeys.length ? mergedQueryKeys : undefined, + }); const handleSubmit = () => { const values = formControl.getValues(); const newData = {}; @@ -33,40 +46,55 @@ export const CippWizardStepButtons = (props) => { sendForm.mutate({ url: postUrl, data: newData }); }; + const buttonStack = ( + + {dialogContext?.onClose && ( + + )} + {currentStep > 0 && ( + + )} + {!noNextButton && currentStep !== lastStep && ( + + )} + {!noSubmitButton && currentStep === lastStep && ( +
+ +
+ )} +
+ ); + return ( <> - - {currentStep > 0 && ( - - )} - {!noNextButton && currentStep !== lastStep && ( - - )} - {!noSubmitButton && currentStep === lastStep && ( -
- -
- )} -
+ {dialogContext?.actionsEl ? createPortal(buttonStack, dialogContext.actionsEl) : buttonStack} ); }; diff --git a/src/contexts/settings-context.js b/src/contexts/settings-context.js index f6f61ffc8649..156403b0c472 100644 --- a/src/contexts/settings-context.js +++ b/src/contexts/settings-context.js @@ -67,6 +67,15 @@ const storeSettings = (value) => { storage.setItem(STORAGE_KEY, JSON.stringify(value)); }; +const stripServerManagedSettings = (settings) => { + if (!settings || typeof settings !== "object") { + return settings; + } + + const { bookmarks, ...cleanedSettings } = settings; + return cleanedSettings; +}; + const initialSettings = { direction: "ltr", paletteMode: "light", @@ -83,6 +92,7 @@ const initialSettings = { breadcrumbMode: "hierarchical", bookmarkSidebar: true, bookmarkPopover: false, + compactNav: false, }; const initialState = { @@ -106,13 +116,20 @@ export const SettingsProvider = (props) => { const restored = restoreSettings(); if (restored) { - if (!restored.currentTheme && restored.paletteMode) { - restored.currentTheme = { value: restored.paletteMode, label: restored.paletteMode }; + const cleanedRestored = stripServerManagedSettings(restored); + + if (!cleanedRestored.currentTheme && cleanedRestored.paletteMode) { + cleanedRestored.currentTheme = { + value: cleanedRestored.paletteMode, + label: cleanedRestored.paletteMode, + }; } + storeSettings(cleanedRestored); + setState((prevState) => ({ ...prevState, - ...restored, + ...cleanedRestored, isInitialized: true, })); } else { @@ -142,16 +159,20 @@ export const SettingsProvider = (props) => { 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) { + if (key !== "bookmarks" && value !== null && value !== undefined) { acc[key] = value; } return acc; }, {}); - return { + const updatedSettings = stripServerManagedSettings({ ...prevState, ...filteredSettings, - }; + }); + + storeSettings(updatedSettings); + + return updatedSettings; }); }, []); @@ -172,13 +193,17 @@ export const SettingsProvider = (props) => { handleUpdate, isCustom, setLastUsedFilter: (page, filter) => { - setState((prevState) => ({ - ...prevState, - lastUsedFilters: { - ...prevState.lastUsedFilters, - [page]: filter, - }, - })); + setState((prevState) => { + const updated = stripServerManagedSettings({ + ...prevState, + lastUsedFilters: { + ...prevState.lastUsedFilters, + [page]: filter, + }, + }); + storeSettings(updated); + return updated; + }); }, }} > diff --git a/src/data/standards.json b/src/data/standards.json index 33d28185ebe6..a776d7f0c943 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -1343,6 +1343,70 @@ "powershellEquivalent": "Update-MgUser -UserId user@domain.com -BodyParameter @{preferredLanguage='en-US'}", "recommendedBy": [] }, + { + "name": "standards.AppManagementPolicy", + "cat": "Entra (AAD) Standards", + "tag": [], + "helpText": "Configures the default app management policy to control application and service principal credential restrictions such as password and key credential lifetimes.", + "docsDescription": "Configures the default app management policy to control application and service principal credential restrictions. This includes password addition restrictions, custom password addition, symmetric key addition, and credential lifetime limits for both applications and service principals.", + "executiveText": "Enforces credential restrictions on application registrations and service principals to limit how secrets and certificates are created and how long they remain valid. This reduces the risk of long-lived or unmanaged credentials being used to access your tenant.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "required": false, + "name": "standards.AppManagementPolicy.passwordCredentialsPasswordAddition", + "label": "Password Addition", + "options": [ + { + "label": "Enabled", + "value": "enabled" + }, + { + "label": "Disabled", + "value": "disabled" + } + ] + }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "required": false, + "name": "standards.AppManagementPolicy.passwordCredentialsCustomPasswordAddition", + "label": "Custom Password", + "options": [ + { + "label": "Enabled", + "value": "enabled" + }, + { + "label": "Disabled", + "value": "disabled" + } + ] + }, + { + "type": "number", + "required": false, + "name": "standards.AppManagementPolicy.passwordCredentialsMaxLifetime", + "label": "Password Credentials Max Lifetime (Days)" + }, + { + "type": "number", + "required": false, + "name": "standards.AppManagementPolicy.keyCredentialsMaxLifetime", + "label": "Key Credentials Max Lifetime (Days)" + } + ], + "label": "Set Default App Management Policy", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-03-13", + "powershellEquivalent": "Graph API", + "recommendedBy": [] + }, { "name": "standards.OutBoundSpamAlert", "cat": "Exchange Standards", diff --git a/src/hooks/use-securescore.js b/src/hooks/use-securescore.js index 66287c8f87bd..542b983e618d 100644 --- a/src/hooks/use-securescore.js +++ b/src/hooks/use-securescore.js @@ -5,15 +5,7 @@ import standards from "../data/standards.json"; export function useSecureScore({ waiting = true } = {}) { const currentTenant = useSettings().currentTenant; - if (currentTenant === "AllTenants") { - return { - controlScore: { isFetching: false, isSuccess: false, data: { Results: [] } }, - secureScore: { isFetching: false, isSuccess: false, data: { Results: [] } }, - translatedData: [], - isFetching: true, - isSuccess: false, - }; - } + const isAllTenants = currentTenant === 'AllTenants'; const [translatedData, setTranslatedData] = useState([]); const [isSuccess, setIsSuccess] = useState(false); @@ -27,7 +19,7 @@ export function useSecureScore({ waiting = true } = {}) { $top: 999, }, queryKey: `controlScore-${currentTenant}`, - waiting: waiting, + waiting: waiting || isAllTenants, }); const secureScore = ApiGetCall({ @@ -40,18 +32,25 @@ export function useSecureScore({ waiting = true } = {}) { $top: 7, }, queryKey: `secureScore-${currentTenant}`, - waiting: waiting, + waiting: waiting || isAllTenants, }); useEffect(() => { + if (isAllTenants) { + setIsFetching(false); + setIsSuccess(false); + setTranslatedData([]); + return; + } if (controlScore.isFetching || secureScore.isFetching) { setIsFetching(true); } else { - setIsFetching(false); + setIsSuccess(false); } - }, [controlScore.isFetching, secureScore.isFetching]); + }, [controlScore.isFetching, secureScore.isFetching, isAllTenants]); useEffect(() => { + if (isAllTenants) return; if (controlScore.isSuccess && secureScore.isSuccess) { const secureScoreData = secureScore.data.Results[0]; const updatedControlScores = secureScoreData.controlScores.map((control) => { @@ -100,7 +99,7 @@ export function useSecureScore({ waiting = true } = {}) { }); setIsSuccess(true); } - }, [controlScore.isSuccess, secureScore.isSuccess, controlScore.data, secureScore.data]); + }, [controlScore.isSuccess, secureScore.isSuccess, controlScore.data, secureScore.data, isAllTenants]); return { controlScore, diff --git a/src/hooks/use-user-bookmarks.js b/src/hooks/use-user-bookmarks.js new file mode 100644 index 000000000000..0e6b1512bb35 --- /dev/null +++ b/src/hooks/use-user-bookmarks.js @@ -0,0 +1,187 @@ +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { ApiGetCall, ApiPostCall } from "../api/ApiCall"; + +const SETTINGS_STORAGE_KEY = "app.settings"; + +const normalizeBookmarks = (value) => { + if (Array.isArray(value)) { + return value; + } + + if ( + value && + typeof value === "object" && + typeof value.path === "string" && + typeof value.label === "string" + ) { + return [value]; + } + + return []; +}; + +const getLocalStoredBookmarks = () => { + if (typeof window === "undefined") { + return []; + } + + try { + const restored = window.localStorage.getItem(SETTINGS_STORAGE_KEY); + if (!restored) { + return []; + } + + const parsed = JSON.parse(restored); + return normalizeBookmarks(parsed?.bookmarks); + } catch { + return []; + } +}; + +const clearLocalStoredBookmarks = () => { + if (typeof window === "undefined") { + return; + } + + try { + const restored = window.localStorage.getItem(SETTINGS_STORAGE_KEY); + if (!restored) { + return; + } + + const parsed = JSON.parse(restored); + if (!parsed || typeof parsed !== "object" || !Object.prototype.hasOwnProperty.call(parsed, "bookmarks")) { + return; + } + + delete parsed.bookmarks; + window.localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(parsed)); + } catch { + return; + } +}; + +const getBookmarksFromSettings = (settingsData) => { + if (!settingsData) { + return []; + } + + if (settingsData.UserBookmarks) { + return normalizeBookmarks(settingsData.UserBookmarks); + } + + if (settingsData.bookmarks) { + return normalizeBookmarks(settingsData.bookmarks); + } + + return []; +}; + +export const useUserBookmarks = () => { + const queryClient = useQueryClient(); + const localMigrationComplete = useRef(false); + const localMigrationInFlight = useRef(false); + + const userSettings = ApiGetCall({ + url: "/api/ListUserSettings", + queryKey: "userSettings", + }); + + const auth = ApiGetCall({ + url: "/api/me", + queryKey: "authmecipp", + }); + + const saveBookmarksPost = ApiPostCall({ + relatedQueryKeys: "userSettings", + }); + + const bookmarks = useMemo(() => { + return getBookmarksFromSettings(userSettings.data); + }, [userSettings.data]); + + const persistBookmarks = useCallback( + (nextBookmarks, callbacks = {}) => { + const safeBookmarks = Array.isArray(nextBookmarks) ? nextBookmarks : []; + + queryClient.setQueryData(["userSettings"], (previous) => ({ + ...(previous || {}), + UserBookmarks: safeBookmarks, + bookmarks: safeBookmarks, + })); + + const user = auth.data?.clientPrincipal?.userDetails; + if (!user) { + return false; + } + + saveBookmarksPost.mutate( + { + url: "/api/ExecUserBookmarks", + data: { + user, + currentSettings: { + bookmarks: safeBookmarks, + }, + }, + }, + callbacks + ); + + return true; + }, + [auth.data?.clientPrincipal?.userDetails, queryClient, saveBookmarksPost] + ); + + const setBookmarks = useCallback( + (nextBookmarks) => { + persistBookmarks(nextBookmarks); + }, + [persistBookmarks] + ); + + useEffect(() => { + if (localMigrationComplete.current || localMigrationInFlight.current) { + return; + } + + if (!auth.data?.clientPrincipal?.userDetails) { + return; + } + + if (bookmarks.length > 0) { + localMigrationComplete.current = true; + return; + } + + const localBookmarks = getLocalStoredBookmarks(); + if (localBookmarks.length === 0) { + localMigrationComplete.current = true; + return; + } + + localMigrationInFlight.current = true; + const didPost = persistBookmarks(localBookmarks, { + onSuccess: () => { + clearLocalStoredBookmarks(); + localMigrationInFlight.current = false; + localMigrationComplete.current = true; + }, + onError: () => { + localMigrationInFlight.current = false; + }, + }); + + if (!didPost) { + localMigrationInFlight.current = false; + } + }, [auth.data?.clientPrincipal?.userDetails, bookmarks.length, persistBookmarks]); + + return { + bookmarks, + setBookmarks, + isLoading: userSettings.isLoading, + isSaving: saveBookmarksPost.isPending, + }; +}; \ No newline at end of file diff --git a/src/layouts/index.js b/src/layouts/index.js index b3c6ea7ba694..b3ef2244cd1f 100644 --- a/src/layouts/index.js +++ b/src/layouts/index.js @@ -86,7 +86,6 @@ export const Layout = (props) => { const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md")); const settings = useSettings(); const mobileNav = useMobileNav(); - const [userSettingsComplete, setUserSettingsComplete] = useState(false); const [fetchingVisible, setFetchingVisible] = useState([]); const [menuItems, setMenuItems] = useState(nativeMenuItems); const lastUserSettingsUpdate = useRef(null); @@ -216,44 +215,27 @@ export const Layout = (props) => { // Only update if the data has actually changed (using dataUpdatedAt as a proxy) const dataUpdatedAt = userSettingsAPI.dataUpdatedAt; if (dataUpdatedAt && dataUpdatedAt !== lastUserSettingsUpdate.current) { + const { bookmarks: _bookmarks, ...serverSettings } = userSettingsAPI.data || {}; //if userSettingsAPI.data contains offboardingDefaults.user, delete that specific key. - if (userSettingsAPI.data.offboardingDefaults?.user) { - delete userSettingsAPI.data.offboardingDefaults.user; + if (serverSettings.offboardingDefaults?.user) { + delete serverSettings.offboardingDefaults.user; } - if (userSettingsAPI.data.offboardingDefaults?.keepCopy) { - delete userSettingsAPI.data.offboardingDefaults.keepCopy; + if (serverSettings.offboardingDefaults?.keepCopy) { + delete serverSettings.offboardingDefaults.keepCopy; } - if (userSettingsAPI?.data?.currentTheme) { - delete userSettingsAPI.data.currentTheme; + if (serverSettings?.currentTheme) { + delete serverSettings.currentTheme; } - // get current devtools settings + // get current devtools settings (device-local only) var showDevtools = settings.showDevtools; - // get current bookmarks and navigation settings (device-local only) - var bookmarks = settings.bookmarks; - var bookmarkSidebar = settings.bookmarkSidebar; - var bookmarkPopover = settings.bookmarkPopover; - var bookmarkReorderMode = settings.bookmarkReorderMode; - var bookmarkLocked = settings.bookmarkLocked; - var bookmarkSortOrder = settings.bookmarkSortOrder; - var bookmarksOpen = settings.bookmarksOpen; - var compactNav = settings.compactNav; settings.handleUpdate({ - ...userSettingsAPI.data, - bookmarks, - bookmarkSidebar, - bookmarkPopover, - bookmarkReorderMode, - bookmarkLocked, - bookmarkSortOrder, - bookmarksOpen, - compactNav, + ...serverSettings, showDevtools, }); // Track this update and set completion status lastUserSettingsUpdate.current = dataUpdatedAt; - setUserSettingsComplete(true); } } }, [ diff --git a/src/layouts/side-nav-bookmarks.js b/src/layouts/side-nav-bookmarks.js index 229a86c88bbf..027513c34635 100644 --- a/src/layouts/side-nav-bookmarks.js +++ b/src/layouts/side-nav-bookmarks.js @@ -14,11 +14,14 @@ import LockOpenIcon from "@mui/icons-material/LockOpen"; import ChevronRightIcon from "@heroicons/react/24/outline/ChevronRightIcon"; import ChevronDownIcon from "@heroicons/react/24/outline/ChevronDownIcon"; import { useSettings } from "../hooks/use-settings"; +import { useUserBookmarks } from "../hooks/use-user-bookmarks"; export const SideNavBookmarks = ({ collapse = false }) => { const settings = useSettings(); const compactNav = settings.compactNav ?? false; const navItemPy = compactNav ? "6px" : "12px"; + const emptyStatePy = compactNav ? "4px" : "8px"; + const { bookmarks, setBookmarks } = useUserBookmarks(); const [open, setOpen] = useState(settings.bookmarksOpen ?? false); const reorderMode = settings.bookmarkReorderMode || "arrows"; const locked = settings.bookmarkLocked ?? false; @@ -42,38 +45,37 @@ export const SideNavBookmarks = ({ collapse = false }) => { const moveBookmarkUp = useCallback( (index) => { if (index <= 0) return; - const updatedBookmarks = [...(settings.bookmarks || [])]; + const updatedBookmarks = [...bookmarks]; const temp = updatedBookmarks[index]; updatedBookmarks[index] = updatedBookmarks[index - 1]; updatedBookmarks[index - 1] = temp; - settings.handleUpdate({ bookmarks: updatedBookmarks }); + setBookmarks(updatedBookmarks); }, - [settings], + [bookmarks, setBookmarks] ); const moveBookmarkDown = useCallback( (index) => { - const bookmarks = settings.bookmarks || []; if (index >= bookmarks.length - 1) return; const updatedBookmarks = [...bookmarks]; const temp = updatedBookmarks[index]; updatedBookmarks[index] = updatedBookmarks[index + 1]; updatedBookmarks[index + 1] = temp; - settings.handleUpdate({ bookmarks: updatedBookmarks }); + setBookmarks(updatedBookmarks); }, - [settings], + [bookmarks, setBookmarks] ); const removeBookmark = useCallback( (path) => { - const updatedBookmarks = [...(settings.bookmarks || [])]; + const updatedBookmarks = [...bookmarks]; const origIdx = updatedBookmarks.findIndex((b) => b.path === path); if (origIdx !== -1) { updatedBookmarks.splice(origIdx, 1); - settings.handleUpdate({ bookmarks: updatedBookmarks }); + setBookmarks(updatedBookmarks); } }, - [settings], + [bookmarks, setBookmarks] ); const animatedMoveUp = useCallback( @@ -97,7 +99,6 @@ export const SideNavBookmarks = ({ collapse = false }) => { const animatedMoveDown = useCallback( (index) => { - const bookmarks = settings.bookmarks || []; if (index >= bookmarks.length - 1 || animatingPair) return; const el1 = itemRefs.current[index]; const el2 = itemRefs.current[index + 1]; @@ -112,7 +113,7 @@ export const SideNavBookmarks = ({ collapse = false }) => { setAnimatingPair(null); }, 250); }, - [animatingPair, settings.bookmarks, moveBookmarkDown], + [animatingPair, bookmarks, moveBookmarkDown] ); const triggerSortFlash = useCallback(() => { @@ -146,15 +147,14 @@ export const SideNavBookmarks = ({ collapse = false }) => { setDragOverIndex(null); return; } - const items = [...(settings.bookmarks || [])]; + const items = [...bookmarks]; const [reordered] = items.splice(dragIndex, 1); items.splice(dropIndex, 0, reordered); - settings.handleUpdate({ bookmarks: items, bookmarkSortOrder: "custom" }); - setSortOrder("custom"); + setBookmarks(items); setDragIndex(null); setDragOverIndex(null); }, - [dragIndex, settings], + [dragIndex, bookmarks, setBookmarks] ); const handleDragEnd = useCallback(() => { @@ -169,13 +169,12 @@ export const SideNavBookmarks = ({ collapse = false }) => { }, [sortOrder, settings]); const displayBookmarks = useMemo(() => { - const bookmarks = settings.bookmarks || []; if (sortOrder === "custom") return bookmarks; return [...bookmarks].sort((a, b) => { const cmp = (a.label || "").localeCompare(b.label || ""); return sortOrder === "asc" ? cmp : -cmp; }); - }, [settings.bookmarks, sortOrder]); + }, [bookmarks, sortOrder]); return (
  • @@ -191,7 +190,7 @@ export const SideNavBookmarks = ({ collapse = false }) => { fontWeight: 500, justifyContent: "flex-start", px: "6px", - py: "12px", + py: navItemPy, textAlign: "left", whiteSpace: "nowrap", width: "100%", @@ -311,7 +310,7 @@ export const SideNavBookmarks = ({ collapse = false }) => { sx={{ pl: "42px", pr: "8px", - py: "8px", + py: emptyStatePy, color: "text.secondary", fontSize: 13, }} @@ -401,11 +400,7 @@ export const SideNavBookmarks = ({ collapse = false }) => { const li = el?.closest("[data-bookmark-index]"); if (li) { const overIdx = parseInt(li.dataset.bookmarkIndex, 10); - if ( - !isNaN(overIdx) && - overIdx >= 0 && - overIdx < (settings.bookmarks || []).length - ) { + if (!isNaN(overIdx) && overIdx >= 0 && overIdx < bookmarks.length) { touchDragRef.current.overIdx = overIdx; setDragOverIndex(overIdx); } @@ -414,11 +409,10 @@ export const SideNavBookmarks = ({ collapse = false }) => { onTouchEnd={() => { const { startIdx, overIdx } = touchDragRef.current; if (startIdx !== null && overIdx !== null && startIdx !== overIdx) { - const items = [...(settings.bookmarks || [])]; + const items = [...bookmarks]; const [reordered] = items.splice(startIdx, 1); items.splice(overIdx, 0, reordered); - settings.handleUpdate({ bookmarks: items, bookmarkSortOrder: "custom" }); - setSortOrder("custom"); + setBookmarks(items); } touchDragRef.current = { startIdx: null, overIdx: null }; setDragIndex(null); diff --git a/src/layouts/side-nav-item.js b/src/layouts/side-nav-item.js index 2a741dfe4ee1..ca6bbdd812ea 100644 --- a/src/layouts/side-nav-item.js +++ b/src/layouts/side-nav-item.js @@ -7,6 +7,7 @@ import ArrowTopRightOnSquareIcon from "@heroicons/react/24/outline/ArrowTopRight import { Box, ButtonBase, Collapse, SvgIcon, Stack } from "@mui/material"; import BookmarkBorderIcon from "@mui/icons-material/BookmarkBorder"; import BookmarkIcon from "@mui/icons-material/Bookmark"; +import { useUserBookmarks } from "../hooks/use-user-bookmarks"; import { useSettings } from "../hooks/use-settings"; export const SideNavItem = (props) => { @@ -24,7 +25,9 @@ export const SideNavItem = (props) => { const [open, setOpen] = useState(openImmediately); const [hovered, setHovered] = useState(false); - const { handleUpdate, bookmarks = [], compactNav = false } = useSettings(); + const { bookmarks, setBookmarks } = useUserBookmarks(); + const settings = useSettings(); + const compactNav = settings.compactNav ?? false; const isBookmarked = bookmarks.some((bookmark) => bookmark.path === path); const handleToggle = useCallback(() => { @@ -34,13 +37,15 @@ export const SideNavItem = (props) => { const handleBookmarkToggle = useCallback( (event) => { event.stopPropagation(); - handleUpdate({ - bookmarks: isBookmarked + setBookmarks( + isBookmarked ? bookmarks.filter((bookmark) => bookmark.path !== path) - : bookmarks.length >= 50 ? bookmarks : [...bookmarks, { label: title, path }], - }); + : bookmarks.length >= 50 + ? bookmarks + : [...bookmarks, { label: title, path }] + ); }, - [isBookmarked, bookmarks, handleUpdate, path, title] + [isBookmarked, bookmarks, setBookmarks, path, title] ); // Dynamic spacing and font sizing based on depth diff --git a/src/layouts/top-nav.js b/src/layouts/top-nav.js index 370fd0f7ddbd..3c8eb4c65dd2 100644 --- a/src/layouts/top-nav.js +++ b/src/layouts/top-nav.js @@ -29,6 +29,7 @@ import { } from "@mui/material"; import { Logo } from "../components/logo"; import { useSettings } from "../hooks/use-settings"; +import { useUserBookmarks } from "../hooks/use-user-bookmarks"; import { paths } from "../paths"; import { AccountPopover } from "./account-popover"; import { CippTenantSelector } from "../components/CippComponents/CippTenantSelector"; @@ -43,6 +44,7 @@ export const TopNav = (props) => { const searchDialog = useDialog(); const { onNavOpen } = props; const settings = useSettings(); + const { bookmarks, setBookmarks } = useUserBookmarks(); const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md")); const showPopoverBookmarks = settings.bookmarkPopover === true; const reorderMode = settings.bookmarkReorderMode || "arrows"; @@ -90,10 +92,10 @@ export const TopNav = (props) => { setDragOverIndex(null); return; } - const items = [...(settings.bookmarks || [])]; + const items = [...bookmarks]; const [reordered] = items.splice(dragIndex, 1); items.splice(dropIndex, 0, reordered); - settings.handleUpdate({ bookmarks: items }); + setBookmarks(items); setDragIndex(null); setDragOverIndex(null); }; @@ -105,29 +107,28 @@ export const TopNav = (props) => { const moveBookmarkUp = (index) => { if (index <= 0) return; - const updatedBookmarks = [...(settings.bookmarks || [])]; + const updatedBookmarks = [...bookmarks]; const temp = updatedBookmarks[index]; updatedBookmarks[index] = updatedBookmarks[index - 1]; updatedBookmarks[index - 1] = temp; - settings.handleUpdate({ bookmarks: updatedBookmarks }); + setBookmarks(updatedBookmarks); }; const moveBookmarkDown = (index) => { - const bookmarks = settings.bookmarks || []; if (index >= bookmarks.length - 1) return; const updatedBookmarks = [...bookmarks]; const temp = updatedBookmarks[index]; updatedBookmarks[index] = updatedBookmarks[index + 1]; updatedBookmarks[index + 1] = temp; - settings.handleUpdate({ bookmarks: updatedBookmarks }); + setBookmarks(updatedBookmarks); }; const removeBookmark = (path) => { - const updatedBookmarks = [...(settings.bookmarks || [])]; + const updatedBookmarks = [...bookmarks]; const origIdx = updatedBookmarks.findIndex((b) => b.path === path); if (origIdx !== -1) { updatedBookmarks.splice(origIdx, 1); - settings.handleUpdate({ bookmarks: updatedBookmarks }); + setBookmarks(updatedBookmarks); } }; @@ -162,7 +163,6 @@ export const TopNav = (props) => { }; const animatedMoveDown = (index) => { - const bookmarks = settings.bookmarks || []; if (index >= bookmarks.length - 1 || animatingPair) return; const el1 = itemRefs.current[index]; const el2 = itemRefs.current[index + 1]; @@ -185,13 +185,12 @@ export const TopNav = (props) => { }; const displayBookmarks = useMemo(() => { - const bookmarks = settings.bookmarks || []; if (sortOrder === "custom") return bookmarks; return [...bookmarks].sort((a, b) => { const cmp = (a.label || "").localeCompare(b.label || ""); return sortOrder === "asc" ? cmp : -cmp; }); - }, [settings.bookmarks, sortOrder]); + }, [bookmarks, sortOrder]); const popoverOpen = Boolean(anchorEl); const popoverId = popoverOpen ? "bookmark-popover" : undefined; @@ -454,11 +453,7 @@ export const TopNav = (props) => { const li = el?.closest("[data-bookmark-index]"); if (li) { const overIdx = parseInt(li.dataset.bookmarkIndex, 10); - if ( - !isNaN(overIdx) && - overIdx >= 0 && - overIdx < (settings.bookmarks || []).length - ) { + if (!isNaN(overIdx) && overIdx >= 0 && overIdx < bookmarks.length) { touchDragRef.current.overIdx = overIdx; setDragOverIndex(overIdx); } @@ -467,10 +462,10 @@ export const TopNav = (props) => { onTouchEnd={() => { const { startIdx, overIdx } = touchDragRef.current; if (startIdx !== null && overIdx !== null && startIdx !== overIdx) { - const items = [...(settings.bookmarks || [])]; + const items = [...bookmarks]; const [reordered] = items.splice(startIdx, 1); items.splice(overIdx, 0, reordered); - settings.handleUpdate({ bookmarks: items }); + setBookmarks(items); } touchDragRef.current = { startIdx: null, overIdx: null }; setDragIndex(null); diff --git a/src/pages/cipp/preferences.js b/src/pages/cipp/preferences.js index fb575a84aae1..f6bbde2e7704 100644 --- a/src/pages/cipp/preferences.js +++ b/src/pages/cipp/preferences.js @@ -99,54 +99,6 @@ const Page = () => { name: "user", }); - // Watch navigation settings and apply immediately (device-local, no server save needed) - const watchedBookmarkSidebar = useWatch({ - control: formcontrol.control, - name: "bookmarkSidebar", - }); - const watchedBookmarkPopover = useWatch({ - control: formcontrol.control, - name: "bookmarkPopover", - }); - const watchedBookmarkReorderMode = useWatch({ - control: formcontrol.control, - name: "bookmarkReorderMode", - }); - const watchedCompactNav = useWatch({ control: formcontrol.control, name: "compactNav" }); - - useEffect(() => { - const updates = {}; - if ( - watchedBookmarkSidebar !== undefined && - watchedBookmarkSidebar !== settings.bookmarkSidebar - ) { - updates.bookmarkSidebar = watchedBookmarkSidebar; - } - if ( - watchedBookmarkPopover !== undefined && - watchedBookmarkPopover !== settings.bookmarkPopover - ) { - updates.bookmarkPopover = watchedBookmarkPopover; - } - if ( - watchedBookmarkReorderMode !== undefined && - watchedBookmarkReorderMode !== settings.bookmarkReorderMode - ) { - updates.bookmarkReorderMode = watchedBookmarkReorderMode; - } - if (watchedCompactNav !== undefined && watchedCompactNav !== settings.compactNav) { - updates.compactNav = watchedCompactNav; - } - if (Object.keys(updates).length > 0) { - settings.handleUpdate(updates); - } - }, [ - watchedBookmarkSidebar, - watchedBookmarkPopover, - watchedBookmarkReorderMode, - watchedCompactNav, - ]); - // Update form when initial user type is determined useEffect(() => { if (initialUserType !== null && auth.data?.clientPrincipal?.userDetails) { @@ -362,7 +314,7 @@ const Page = () => { { const [editTaskId, setEditTaskId] = useState(null); const [cloneTaskId, setCloneTaskId] = useState(null); + const currentTenant = useSettings().currentTenant; const drawerHandlers = { openEditDrawer: (row) => { @@ -67,9 +69,13 @@ const Page = () => { tenantInTitle={false} title="Scheduled Tasks" apiUrl={ - showHiddenJobs ? "/api/ListScheduledItems?ShowHidden=true" : "/api/ListScheduledItems" + showHiddenJobs ? `/api/ListScheduledItems?ShowHidden=true` : `/api/ListScheduledItems` + } + queryKey={ + showHiddenJobs + ? `ListScheduledItems-hidden-${currentTenant}` + : `ListScheduledItems-${currentTenant}` } - queryKey={showHiddenJobs ? `ListScheduledItems-hidden` : `ListScheduledItems`} simpleColumns={[ "ExecutedTime", "TaskState", diff --git a/src/pages/cipp/settings/password-config/index.js b/src/pages/cipp/settings/password-config/index.js new file mode 100644 index 000000000000..540dd2853b2f --- /dev/null +++ b/src/pages/cipp/settings/password-config/index.js @@ -0,0 +1,329 @@ +import { useEffect, useState, useCallback } from "react"; +import { + Alert, + Box, + Button, + Card, + CardContent, + Container, + Divider, + FormControlLabel, + Stack, + SvgIcon, + Switch, + TextField, + ToggleButton, + ToggleButtonGroup, + Typography, +} from "@mui/material"; +import { Grid } from "@mui/system"; +import { Layout as DashboardLayout } from "../../../../layouts/index.js"; +import { useRouter } from "next/router"; +import { ApiGetCall, ApiPostCall } from "../../../../api/ApiCall"; +import { ArrowLeftIcon, CheckIcon } from "@heroicons/react/24/outline"; +import { CippHead } from "../../../../components/CippComponents/CippHead"; +import { CippApiResults } from "../../../../components/CippComponents/CippApiResults"; + +// Password configuration constants +const PASSWORD_TYPES = { + CLASSIC: 'Classic', + PASSPHRASE: 'Passphrase' +}; + +const DEFAULT_VALUES = { + CHAR_COUNT: 14, + WORD_COUNT: 4, + SPECIAL_CHAR_SET: '$%&*#', + SEPARATOR: '-' +}; + +function normalizeConfigForBackend(config) { + return { + passwordType: String(config.passwordType || PASSWORD_TYPES.CLASSIC), + charCount: String(parseInt(config.charCount, 10) || DEFAULT_VALUES.CHAR_COUNT), + includeUppercase: String(Boolean(config.includeUppercase)), + includeLowercase: String(Boolean(config.includeLowercase)), + includeDigits: String(Boolean(config.includeDigits)), + includeSpecialChars: String(Boolean(config.includeSpecialChars)), + specialCharSet: String(config.specialCharSet || DEFAULT_VALUES.SPECIAL_CHAR_SET), + wordCount: String(parseInt(config.wordCount, 10) || DEFAULT_VALUES.WORD_COUNT), + separator: config.separator !== undefined && config.separator !== null ? String(config.separator) : DEFAULT_VALUES.SEPARATOR, + capitalizeWords: String(Boolean(config.capitalizeWords)), + appendNumber: String(Boolean(config.appendNumber)), + appendSpecialChar: String(Boolean(config.appendSpecialChar)), + }; +} + + +const DEFAULT_CONFIG = { + passwordType: PASSWORD_TYPES.CLASSIC, + charCount: String(DEFAULT_VALUES.CHAR_COUNT), + includeUppercase: true, + includeLowercase: true, + includeDigits: true, + includeSpecialChars: true, + specialCharSet: DEFAULT_VALUES.SPECIAL_CHAR_SET, + wordCount: String(DEFAULT_VALUES.WORD_COUNT), + separator: DEFAULT_VALUES.SEPARATOR, + capitalizeWords: false, + appendNumber: false, + appendSpecialChar: false, +}; + +// ── Page ────────────────────────────────────────────────────────────────────── + +const Page = () => { + const router = useRouter(); + const [config, setConfig] = useState(DEFAULT_CONFIG); + + const passwordSetting = ApiGetCall({ url: "/api/ExecPasswordConfig?list=true", queryKey: "PasswordSettings" }); + const passwordSave = ApiPostCall({ datafromUrl: true, relatedQueryKeys: "PasswordSettings" }); + + useEffect(() => { + if (passwordSetting.isSuccess && passwordSetting.data) { + const r = passwordSetting.data.Results; + const toBool = (v, def) => { + if (v === undefined || v === null) return def; + if (typeof v === 'boolean') return v; + if (typeof v === 'string') return v.toLowerCase() === 'true'; + if (typeof v === 'number') return v === 1; + return def; + }; + + setConfig({ + passwordType: r.passwordType || DEFAULT_CONFIG.passwordType, + charCount: String(parseInt(r.charCount, 10) || DEFAULT_CONFIG.charCount), + includeUppercase: toBool(r.includeUppercase, DEFAULT_CONFIG.includeUppercase), + includeLowercase: toBool(r.includeLowercase, DEFAULT_CONFIG.includeLowercase), + includeDigits: toBool(r.includeDigits, DEFAULT_CONFIG.includeDigits), + includeSpecialChars: toBool(r.includeSpecialChars, DEFAULT_CONFIG.includeSpecialChars), + specialCharSet: r.specialCharSet || DEFAULT_CONFIG.specialCharSet, + wordCount: String(parseInt(r.wordCount, 10) || DEFAULT_CONFIG.wordCount), + separator: r.separator !== undefined ? r.separator : DEFAULT_CONFIG.separator, + capitalizeWords: toBool(r.capitalizeWords, DEFAULT_CONFIG.capitalizeWords), + appendNumber: toBool(r.appendNumber, DEFAULT_CONFIG.appendNumber), + appendSpecialChar: toBool(r.appendSpecialChar, DEFAULT_CONFIG.appendSpecialChar), + }); + } + }, [passwordSetting.isSuccess, passwordSetting.data]); + + const set = useCallback((field, value) => { + setConfig((p) => ({ ...p, [field]: value })); + }, []); + + const isClassic = config.passwordType === PASSWORD_TYPES.CLASSIC; + + const handleSave = () => { + const normalizedConfig = normalizeConfigForBackend(config); + + passwordSave.mutate( + { + url: "/api/ExecPasswordConfig", + data: normalizedConfig, + queryKey: "PasswordSettingsPost", + } + ); + }; + + const handleBackToSettings = () => { + router.push("/cipp/settings"); + }; + + return ( + <> + + + + + +
    + Password Configuration + +
    +
    + + + + + + Type + + v && set("passwordType", v)} + size="small" + color="primary" + > + Classic + Passphrase + + + + + + {isClassic + ? "Random characters from the selected classes. Good for systems requiring specific character types. 16+ characters recommended for strong security." + : "Random dictionary words joined together. Easier to remember and typically stronger at equal length. 5+ words recommended for high security."} + + + + {isClassic ? ( + <> + + { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + set("charCount", value); + } + }} + size="small" + sx={{ width: 120 }} + inputProps={{ + style: { height: "40px" }, + min: 8, + max: 256 + }} + error={config.charCount === ''} + helperText={config.charCount === '' ? "Length cannot be empty" : ""} + /> + + + + set("includeUppercase", e.target.checked)} />} + label={Uppercase (A-Z)} + /> + + + set("includeLowercase", e.target.checked)} />} + label={Lowercase (a-z)} + /> + + + set("includeDigits", e.target.checked)} />} + label={Digits (0-9)} + /> + + + set("includeSpecialChars", e.target.checked)} />} + label={Special Characters} + /> + + + {config.includeSpecialChars && ( + set("specialCharSet", e.target.value)} + size="small" + fullWidth + helperText="Allowed: !@#$%^&*()-_=+/" + /> + )} + + ) : ( + <> + + { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + set("wordCount", value); + } + }} + size="small" + sx={{ width: 120, maxWidth: 160 }} + inputProps={{ + style: { height: "40px" }, + min: 2, + max: 10 + }} + error={config.wordCount === ''} + helperText={config.wordCount === '' ? "Word count cannot be empty" : ""} + /> + set("separator", e.target.value)} + size="small" + sx={{ maxWidth: 120 }} + /> + + Allowed: single space, empty, or !@#$%^&*()-_=+/ + + + + + set("capitalizeWords", e.target.checked)} />} + label={Capitalize words} + /> + + + set("appendNumber", e.target.checked)} />} + label={Append number} + /> + + + set("appendSpecialChar", e.target.checked)} />} + label={Append Special Character} + /> + + + {config.appendSpecialChar && ( + set("specialCharSet", e.target.value)} + size="small" + fullWidth + helperText="Allowed: !@#$%^&*()-_=+/" + /> + )} + + )} + + + + +
    +
    +
    + + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; diff --git a/src/pages/cipp/settings/tenants.js b/src/pages/cipp/settings/tenants.js index 3ce10da19c2d..c6cd965fb2ac 100644 --- a/src/pages/cipp/settings/tenants.js +++ b/src/pages/cipp/settings/tenants.js @@ -1,12 +1,201 @@ +import { Button, SvgIcon } from "@mui/material"; +import { CippTablePage } from "../../../components/CippComponents/CippTablePage.jsx"; +import { CippApiDialog } from "../../../components/CippComponents/CippApiDialog"; +import { useCippGDAPTrace } from "../../../components/CippSettings/CippGDAP/CippGDAPTrace"; import { Layout as DashboardLayout } from "../../../layouts/index.js"; import { TabbedLayout } from "../../../layouts/TabbedLayout"; import tabOptions from "./tabOptions"; -import { CippTenantTable } from "../../../components/CippWizard/CippTenantTable"; +import { useDialog } from "../../../hooks/use-dialog"; +import { + Sync, + Block, + PlayArrow, + RestartAlt, + Delete, + Add, + Refresh, +} from "@mui/icons-material"; +import cacheTypes from "../../../data/CIPPDBCacheTypes.json"; const Page = () => { const pageTitle = "Tenants - Backend"; + const createDialog = useDialog(); + const { ref: gdapRef, traceGdapAction, CippGDAPTrace } = useCippGDAPTrace(); - return ; + const actions = [ + { + label: "Exclude Tenants", + type: "POST", + url: "/api/ExecExcludeTenant?AddExclusion=true", + icon: , + data: { value: "customerId" }, + confirmText: "Are you sure you want to exclude [displayName]?", + multiPost: false, + condition: (row) => row.displayName !== "*Partner Tenant", + }, + { + label: "Include Tenants", + type: "POST", + url: "/api/ExecExcludeTenant?RemoveExclusion=true", + icon: , + data: { value: "customerId" }, + confirmText: "Are you sure you want to include [displayName]?", + multiPost: false, + condition: (row) => row.displayName !== "*Partner Tenant", + }, + { + label: "Refresh CPV Permissions", + type: "POST", + url: "/api/ExecCPVPermissions", + icon: , + data: { tenantFilter: "customerId" }, + confirmText: "Are you sure you want to refresh the CPV permissions for [displayName]?", + multiPost: false, + }, + { + label: "Reset CPV Permissions", + type: "POST", + url: "/api/ExecCPVPermissions?&ResetSP=true", + icon: , + data: { tenantFilter: "customerId" }, + confirmText: + "Are you sure you want to reset the CPV permissions for [displayName]? (This will delete the Service Principal and re-add it.)", + multiPost: false, + condition: (row) => + row.displayName !== "*Partner Tenant" && row.delegatedPrivilegeStatus !== "directTenant", + }, + { + label: "Remove Tenant", + type: "POST", + url: "/api/ExecRemoveTenant", + icon: , + data: { TenantID: "customerId" }, + confirmText: + "Are you sure you want to remove [displayName]? If this is a Direct Tenant, this will no longer be accessible until you add it via the Setup Wizard.", + multiPost: false, + condition: (row) => row.displayName !== "*Partner Tenant", + }, + { + label: "Refresh CIPPDB Cache", + type: "GET", + url: "/api/ExecCIPPDBCache", + icon: , + data: { Name: "Name", TenantFilter: "customerId" }, + confirmText: "Select the cache type to refresh for [displayName]:", + multiPost: false, + hideBulk: true, + fields: [ + { + type: "autoComplete", + name: "Name", + label: "Cache Type", + placeholder: "Select a cache type", + options: cacheTypes.map((cacheType) => ({ + label: cacheType.friendlyName, + value: cacheType.type, + description: cacheType.description, + })), + multiple: false, + creatable: false, + required: true, + }, + ], + customDataformatter: (rowData, actionData, formData) => { + const tenantFilter = rowData?.customerId || rowData?.defaultDomainName || ""; + const cacheTypeName = formData.Name?.value || formData.Name || ""; + return { + Name: cacheTypeName, + TenantFilter: tenantFilter, + }; + }, + }, + traceGdapAction, + ]; + + const offCanvas = { + extendedInfoFields: [ + "displayName", + "defaultDomainName", + "delegatedPrivilegeStatus", + "Excluded", + "ExcludeDate", + "ExcludeUser", + ], + actions: actions, + }; + + const simpleColumns = [ + "displayName", + "defaultDomainName", + "delegatedPrivilegeStatus", + "Excluded", + "ExcludeDate", + "ExcludeUser", + ]; + + const filters = [ + { + filterName: "Included tenants", + value: [{ id: "Excluded", value: "No" }], + type: "column", + }, + { + filterName: "Excluded tenants", + value: [{ id: "Excluded", value: "Yes" }], + type: "column", + }, + ]; + + return ( + <> + + + + + Force Refresh + + } + /> + + + + ); }; Page.getLayout = (page) => ( diff --git a/src/pages/dashboardv2/index.js b/src/pages/dashboardv2/index.js index eeb351d6f3a2..fcb983b4e36d 100644 --- a/src/pages/dashboardv2/index.js +++ b/src/pages/dashboardv2/index.js @@ -100,8 +100,12 @@ const Page = () => { }); const currentTenantInfo = ApiGetCall({ - url: "/api/ListTenants", - queryKey: `ListTenants`, + url: "/api/listTenants", + data: { AllTenantSelector: true }, + queryKey: "TenantSelector", + refetchOnMount: false, + refetchOnReconnect: false, + keepPreviousData: true, }); const reportData = diff --git a/src/pages/identity/administration/offboarding-wizard/index.js b/src/pages/identity/administration/offboarding-wizard/index.js index 931cfded2eef..6bf7ec6ff0c8 100644 --- a/src/pages/identity/administration/offboarding-wizard/index.js +++ b/src/pages/identity/administration/offboarding-wizard/index.js @@ -5,9 +5,30 @@ import { CippTenantStep } from "../../../../components/CippWizard/CippTenantStep import { CippWizardAutoComplete } from "../../../../components/CippWizard/CippWizardAutoComplete"; import { CippWizardOffboarding } from "../../../../components/CippWizard/CippWizardOffboarding"; import { useSettings } from "../../../../hooks/use-settings"; +import CippTablePage from "../../../../components/CippComponents/CippTablePage"; +import { PersonOff } from "@mui/icons-material"; +import { Button } from "@mui/material"; +import { useState } from "react"; +import ScheduledTaskDetails from "../../../../components/CippComponents/ScheduledTaskDetails"; +import { CippScheduledTaskActions } from "../../../../components/CippComponents/CippScheduledTaskActions"; +import { CippSchedulerDrawer } from "../../../../components/CippComponents/CippSchedulerDrawer"; const Page = () => { + const [wizardOpen, setWizardOpen] = useState(false); + const [editTaskId, setEditTaskId] = useState(null); + const [cloneTaskId, setCloneTaskId] = useState(null); const initialState = useSettings(); + const currentTenant = initialState.currentTenant; + + const drawerHandlers = { + openEditDrawer: (row) => setEditTaskId(row.RowKey), + openCloneDrawer: (row) => setCloneTaskId(row.RowKey), + }; + + const actions = CippScheduledTaskActions(drawerHandlers, { + hideActions: ["Edit Job", "Clone Job"], + }); + const steps = [ { title: "Step 1", @@ -61,10 +82,92 @@ const Page = () => { }, ]; + const filterList = [ + { + filterName: "Running", + value: [{ id: "TaskState", value: "Running" }], + type: "column", + }, + { + filterName: "Planned", + value: [{ id: "TaskState", value: "Planned" }], + type: "column", + }, + { + filterName: "Failed", + value: [{ id: "TaskState", value: "Failed" }], + type: "column", + }, + { + filterName: "Completed", + value: [{ id: "TaskState", value: "Completed" }], + type: "column", + }, + ]; + + const offCanvas = { + children: (extendedData) => ( + + ), + size: "xl", + actions: actions, + }; + return ( <> + setWizardOpen(true)} startIcon={}> + Start Offboarding + + } + title="User Offboarding" + apiUrl="/api/ListScheduledItems?Type=Invoke-CIPPOffboardingJob&" + queryKey={`OffboardingJobs-${currentTenant}`} + actions={actions} + simpleColumns={[ + "Tenant", + "Parameters.Username", + "TaskState", + "ScheduledTime", + "ExecutedTime", + ]} + filters={filterList} + offCanvas={offCanvas} + /> + + {/* Edit Drawer */} + {editTaskId && ( + setEditTaskId(null)} + onClose={() => setEditTaskId(null)} + PermissionButton={({ children }) => <>{children}} + /> + )} + + {/* Clone Drawer */} + {cloneTaskId && ( + setCloneTaskId(null)} + onClose={() => setCloneTaskId(null)} + PermissionButton={({ children }) => <>{children}} + /> + )} setWizardOpen(false)} + dialogIcon={} + relatedQueryKeys={[`OffboardingJobs-${currentTenant}`]} + initialState={{ + ...initialState.offboardingDefaults, + ...{ Scheduled: { enabled: false } }, + }} steps={steps} postUrl="/api/ExecOffboardUser" wizardTitle="User Offboarding Wizard" diff --git a/src/pages/identity/administration/vacation-mode/index.js b/src/pages/identity/administration/vacation-mode/index.js index 1b85952156e1..f2caa0ec2468 100644 --- a/src/pages/identity/administration/vacation-mode/index.js +++ b/src/pages/identity/administration/vacation-mode/index.js @@ -5,8 +5,12 @@ import { EyeIcon } from "@heroicons/react/24/outline"; import { Button } from "@mui/material"; import Link from "next/link"; import { EventAvailable } from "@mui/icons-material"; +import { useSettings } from "../../../../hooks/use-settings.js"; const Page = () => { + const initialState = useSettings(); + const currentTenant = initialState.currentTenant; + const actions = [ { label: "View Task Details", @@ -76,17 +80,9 @@ const Page = () => { } title="Vacation Mode" apiUrl="/api/ListScheduledItems?SearchTitle=*Vacation*" - queryKey="VacationMode" - tenantInTitle={false} + queryKey={`VacationMode-${currentTenant}`} actions={actions} - simpleColumns={[ - "Tenant", - "Name", - "Reference", - "TaskState", - "ScheduledTime", - "ExecutedTime", - ]} + simpleColumns={["Tenant", "Name", "Reference", "TaskState", "ScheduledTime", "ExecutedTime"]} filters={filterList} offCanvas={{ extendedInfoFields: [ diff --git a/src/pages/teams-share/sharepoint/index.js b/src/pages/teams-share/sharepoint/index.js index b92b04c5c5aa..21cefc406ca4 100644 --- a/src/pages/teams-share/sharepoint/index.js +++ b/src/pages/teams-share/sharepoint/index.js @@ -199,11 +199,9 @@ const Page = () => { title="Site Members" queryKey={`site-members-${row.siteId}`} api={{ - url: "/api/ListGraphRequest", + url: "/api/ListSiteMembers", data: { - Endpoint: `/sites/${row.siteId}/lists/User%20Information%20List/items`, - AsApp: "true", - expand: "fields", + SiteId: row.siteId, tenantFilter: tenantFilter, }, dataKey: "Results", diff --git a/src/pages/tenant/administration/tenants/groups/index.js b/src/pages/tenant/administration/tenants/groups/index.js index ca49669462af..5095155edfef 100644 --- a/src/pages/tenant/administration/tenants/groups/index.js +++ b/src/pages/tenant/administration/tenants/groups/index.js @@ -2,7 +2,7 @@ import { Layout as DashboardLayout } from "../../../../../layouts/index.js"; import { TabbedLayout } from "../../../../../layouts/TabbedLayout"; import { CippTablePage } from "../../../../../components/CippComponents/CippTablePage.jsx"; import tabOptions from "../tabOptions"; -import { Edit, PlayArrow, GroupAdd } from "@mui/icons-material"; +import { Edit, PlayArrow, GroupAdd, ViewList } from "@mui/icons-material"; import { TrashIcon } from "@heroicons/react/24/outline"; import { CippAddTenantGroupDrawer } from "../../../../../components/CippComponents/CippAddTenantGroupDrawer"; import { CippApiLogsDrawer } from "../../../../../components/CippComponents/CippApiLogsDrawer"; @@ -10,12 +10,16 @@ import { CippTenantGroupOffCanvas } from "../../../../../components/CippComponen import { CippApiDialog } from "../../../../../components/CippComponents/CippApiDialog.jsx"; import { Box, Button } from "@mui/material"; import { useDialog } from "../../../../../hooks/use-dialog.js"; +import { useState } from "react" const Page = () => { const pageTitle = "Tenant Groups"; const createDefaultGroupsDialog = useDialog(); + const [showUsage, setShowUsage] = useState(false); - const simpleColumns = ["Name", "Description", "GroupType", "Members"]; + const simpleColumns = showUsage + ? ["Name", "Description", "GroupType", "Members", "Usage"] + : ["Name", "Description", "GroupType", "Members"]; const offcanvas = { children: (row) => { @@ -57,12 +61,16 @@ const Page = () => { tenantInTitle={false} simpleColumns={simpleColumns} apiUrl="/api/ListTenantGroups" - queryKey="TenantGroupListPage" + apiData={{ includeUsage: showUsage }} + queryKey={showUsage ? "TenantGroupListPage-usage" : "TenantGroupListPage"} apiDataKey="Results" actions={actions} cardButton={ + diff --git a/src/pages/tenant/standards/templates/template.jsx b/src/pages/tenant/standards/templates/template.jsx index a64c30f67276..19cf27c788f2 100644 --- a/src/pages/tenant/standards/templates/template.jsx +++ b/src/pages/tenant/standards/templates/template.jsx @@ -398,7 +398,7 @@ const Page = () => { - + {/* Left Column for Accordions */} diff --git a/src/utils/get-cipp-filter-variant.js b/src/utils/get-cipp-filter-variant.js index ab59e94bcc32..539fbd464cfb 100644 --- a/src/utils/get-cipp-filter-variant.js +++ b/src/utils/get-cipp-filter-variant.js @@ -50,6 +50,12 @@ export const getCippFilterVariant = (providedColumnKeys, arg) => { })); } + // Add "No Licenses Assigned" option at beginning + filterSelectOptions.unshift({ + label: "No Licenses Assigned", + value: "__no_license__", + }); + return { filterVariant: "multi-select", sortingFn: "alphanumeric", @@ -58,11 +64,28 @@ export const getCippFilterVariant = (providedColumnKeys, arg) => { if (!filterValue || !Array.isArray(filterValue) || filterValue.length === 0) { return true; } - if (!userLicenses || !Array.isArray(userLicenses) || userLicenses.length === 0) { + + const hasNoLicenseFilter = filterValue.includes("__no_license__"); + const otherFilters = filterValue.filter((v) => v !== "__no_license__"); + const isUnlicensed = !userLicenses || !Array.isArray(userLicenses) || userLicenses.length === 0; + + // If user selected "No Licenses Assigned" and this user is unlicensed → match + if (hasNoLicenseFilter && isUnlicensed) { + return true; + } + + // If only "No Licenses Assigned" is selected and user has licenses → no match + if (hasNoLicenseFilter && otherFilters.length === 0 && !isUnlicensed) { return false; } + + // Check other license filters + if (isUnlicensed) { + return false; + } + const userSkuIds = userLicenses.map((license) => license.skuId).filter(Boolean); - return filterValue.some((selectedSkuId) => userSkuIds.includes(selectedSkuId)); + return otherFilters.some((selectedSkuId) => userSkuIds.includes(selectedSkuId)); }, filterSelectOptions: filterSelectOptions, }; diff --git a/src/utils/get-cipp-formatting.js b/src/utils/get-cipp-formatting.js index 538116993b53..217b1ae2a6a1 100644 --- a/src/utils/get-cipp-formatting.js +++ b/src/utils/get-cipp-formatting.js @@ -430,7 +430,7 @@ export const getCippFormatting = (data, cellName, type, canReceive, flatten = tr ); } - if (cellName === "ClientId" || cellName === "role" || cellName === "appId") { + if (cellName === "ClientId" || cellName === "role" || cellName === "appId" || cellName === "SID") { return isText ? data : ; } @@ -643,6 +643,23 @@ export const getCippFormatting = (data, cellName, type, canReceive, flatten = tr ); } + // Handle businessPhones + if (cellName === "businessPhones") { + if (!Array.isArray(data)) { + data = [data]; + } + + if (data.length === 0) { + return isText ? ( + "No data" + ) : ( + + ); + } + + return isText ? data.join(", ") : renderChipList(data); + } + //handle assignedUsers if (cellName === "AssignedUsers" || cellName === "assignedUsers") { //show the display name in text. otherwise, just return the obj. diff --git a/yarn.lock b/yarn.lock index 605fa06ca1d0..d9c51cd68ed2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2255,20 +2255,20 @@ resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.11.2.tgz#00409e743ac4eea9afe5b7708594d5fcebb00212" integrity sha512-vTtpNt7mKCiZ1pwU9hfKPhpdVO2sVzFQsxoVBGtOSHxlrRRzYr8iQ2TlwbAcRYCcEiZ9ECAM8kBzH0v2+VzfKw== -"@tiptap/core@^3.20.0", "@tiptap/core@^3.4.1": - version "3.20.0" - resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-3.20.0.tgz#dac72894d83829f2fbbabee2e90a748d7c1479ee" - integrity sha512-aC9aROgia/SpJqhsXFiX9TsligL8d+oeoI8W3u00WI45s0VfsqjgeKQLDLF7Tu7hC+7F02teC84SAHuup003VQ== +"@tiptap/core@^3.20.1", "@tiptap/core@^3.4.1": + version "3.20.1" + resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-3.20.1.tgz#3e870175541144cc9ca292c804609f7c43549a8b" + integrity sha512-SwkPEWIfaDEZjC8SEIi4kZjqIYUbRgLUHUuQezo5GbphUNC8kM1pi3C3EtoOPtxXrEbY6e4pWEzW54Pcrd+rVA== -"@tiptap/extension-blockquote@^3.20.0": - version "3.20.0" - resolved "https://registry.yarnpkg.com/@tiptap/extension-blockquote/-/extension-blockquote-3.20.0.tgz#92e4a8ed00cf4fcab056766b848fc0a551847e5b" - integrity sha512-LQzn6aGtL4WXz2+rYshl/7/VnP2qJTpD7fWL96GXAzhqviPEY1bJES7poqJb3MU/gzl8VJUVzVzU1VoVfUKlbA== +"@tiptap/extension-blockquote@^3.20.1": + version "3.20.1" + resolved "https://registry.yarnpkg.com/@tiptap/extension-blockquote/-/extension-blockquote-3.20.1.tgz#7991cbe4250f4389c80fc06adc499ad1e65cb7bf" + integrity sha512-WzNXk/63PQI2fav4Ta6P0GmYRyu8Gap1pV3VUqaVK829iJ6Zt1T21xayATHEHWMK27VT1GLPJkx9Ycr2jfDyQw== -"@tiptap/extension-bold@^3.20.0": - version "3.20.0" - resolved "https://registry.yarnpkg.com/@tiptap/extension-bold/-/extension-bold-3.20.0.tgz#4298d24cb6c7759f6233eec5e24e9fd7c7efdf38" - integrity sha512-sQklEWiyf58yDjiHtm5vmkVjfIc/cBuSusmCsQ0q9vGYnEF1iOHKhGpvnCeEXNeqF3fiJQRlquzt/6ymle3Iwg== +"@tiptap/extension-bold@^3.20.1": + version "3.20.1" + resolved "https://registry.yarnpkg.com/@tiptap/extension-bold/-/extension-bold-3.20.1.tgz#c5dda7450cb1d575ee3f53fafd02fb0169151df6" + integrity sha512-fz++Qv6Rk/Hov0IYG/r7TJ1Y4zWkuGONe0UN5g0KY32NIMg3HeOHicbi4xsNWTm9uAOl3eawWDkezEMrleObMw== "@tiptap/extension-bubble-menu@^3.13.0": version "3.13.0" @@ -2277,127 +2277,127 @@ dependencies: "@floating-ui/dom" "^1.0.0" -"@tiptap/extension-bullet-list@^3.20.0": - version "3.20.0" - resolved "https://registry.yarnpkg.com/@tiptap/extension-bullet-list/-/extension-bullet-list-3.20.0.tgz#57bcba5988990f39cb71c7901173da9b4979523b" - integrity sha512-OcKMeopBbqWzhSi6o8nNz0aayogg1sfOAhto3NxJu3Ya32dwBFqmHXSYM6uW4jOphNvVPyjiq9aNRh3qTdd1dw== +"@tiptap/extension-bullet-list@^3.20.1": + version "3.20.1" + resolved "https://registry.yarnpkg.com/@tiptap/extension-bullet-list/-/extension-bullet-list-3.20.1.tgz#7184db6e65533904d65d403690784f5fea508208" + integrity sha512-mbrlvOZo5OF3vLhp+3fk9KuL/6J/wsN0QxF6ZFRAHzQ9NkJdtdfARcBeBnkWXGN8inB6YxbTGY1/E4lmBkOpOw== -"@tiptap/extension-code-block@^3.20.0": - version "3.20.0" - resolved "https://registry.yarnpkg.com/@tiptap/extension-code-block/-/extension-code-block-3.20.0.tgz#5c00e8ae017c32ff4dd629447635a25fcb9d0f52" - integrity sha512-lBbmNek14aCjrHcBcq3PRqWfNLvC6bcRa2Osc6e/LtmXlcpype4f6n+Yx+WZ+f2uUh0UmDRCz7BEyUETEsDmlQ== +"@tiptap/extension-code-block@^3.20.1": + version "3.20.1" + resolved "https://registry.yarnpkg.com/@tiptap/extension-code-block/-/extension-code-block-3.20.1.tgz#c4b69ff6eb0929700cfd70aedee0754960efb17d" + integrity sha512-vKejwBq+Nlj4Ybd3qOyDxIQKzYymdNH+8eXkKwGShk2nfLJIxq69DCyGvmuHgipIO1qcYPJ149UNpGN+YGcdmA== -"@tiptap/extension-code@^3.20.0": - version "3.20.0" - resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-3.20.0.tgz#6aa18bc21ed8a3a6a899653c48add1cecc64783d" - integrity sha512-TYDWFeSQ9umiyrqsT6VecbuhL8XIHkUhO+gEk0sVvH67ZLwjFDhAIIgWIr1/dbIGPcvMZM19E7xUUhAdIaXaOQ== +"@tiptap/extension-code@^3.20.1": + version "3.20.1" + resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-3.20.1.tgz#611b95bb1b583fdca53b39d3abd46165c7fe3721" + integrity sha512-509DHINIA/Gg+eTG7TEkfsS8RUiPLH5xZNyLRT0A1oaoaJmECKfrV6aAm05IdfTyqDqz6LW5pbnX6DdUC4keug== -"@tiptap/extension-document@^3.20.0": - version "3.20.0" - resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-3.20.0.tgz#e10f92139c97354ab917f3095d4011d6703528f6" - integrity sha512-oJfLIG3vAtZo/wg29WiBcyWt22KUgddpP8wqtCE+kY5Dw8znLR9ehNmVWlSWJA5OJUMO0ntAHx4bBT+I2MBd5w== +"@tiptap/extension-document@^3.20.1": + version "3.20.1" + resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-3.20.1.tgz#b8cc670096ad755f3c0daa1f5297aa7536947ff0" + integrity sha512-9vrqdGmRV7bQCSY3NLgu7UhIwgOCDp4sKqMNsoNRX0aZ021QQMTvBQDPkiRkCf7MNsnWrNNnr52PVnULEn3vFQ== -"@tiptap/extension-dropcursor@^3.20.0": - version "3.20.0" - resolved "https://registry.yarnpkg.com/@tiptap/extension-dropcursor/-/extension-dropcursor-3.20.0.tgz#459d6c5d7e5f4dc1152246901387e000596fbb40" - integrity sha512-d+cxplRlktVgZPwatnc34IArlppM0IFKS1J5wLk+ba1jidizsbMVh45tP/BTK2flhyfRqcNoB5R0TArhUpbkNQ== +"@tiptap/extension-dropcursor@^3.20.1": + version "3.20.1" + resolved "https://registry.yarnpkg.com/@tiptap/extension-dropcursor/-/extension-dropcursor-3.20.1.tgz#c2e3f8536164258c7532da32bb132c082c8e625d" + integrity sha512-K18L9FX4znn+ViPSIbTLOGcIaXMx/gLNwAPE8wPLwswbHhQqdiY1zzdBw6drgOc1Hicvebo2dIoUlSXOZsOEcw== "@tiptap/extension-floating-menu@^3.13.0": version "3.13.0" resolved "https://registry.yarnpkg.com/@tiptap/extension-floating-menu/-/extension-floating-menu-3.13.0.tgz#03d03292add49d1b380cdb1ff3890b2956d4e3f5" integrity sha512-OsezV2cMofZM4c13gvgi93IEYBUzZgnu8BXTYZQiQYekz4bX4uulBmLa1KOA9EN71FzS+SoLkXHU0YzlbLjlxA== -"@tiptap/extension-gapcursor@^3.20.0": - version "3.20.0" - resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-3.20.0.tgz#df89dd19417c020c6e9529e2db5077d08353e4a1" - integrity sha512-P/LasfvG9/qFq43ZAlNbAnPnXC+/RJf49buTrhtFvI9Zg0+Lbpjx1oh6oMHB19T88Y28KtrckfFZ8aTSUWDq6w== +"@tiptap/extension-gapcursor@^3.20.1": + version "3.20.1" + resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-3.20.1.tgz#b1ba47085c3a9deb4d6a899c85ac358b33e37772" + integrity sha512-kZOtttV6Ai8VUAgEng3h4WKFbtdSNJ6ps7r0cRPY+FctWhVmgNb/JJwwyC+vSilR7nRENAhrA/Cv/RxVlvLw+g== -"@tiptap/extension-hard-break@^3.20.0": - version "3.20.0" - resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-3.20.0.tgz#fff1553d3b41ad32d3979d618f0f894cee926f46" - integrity sha512-rqvhMOw4f+XQmEthncbvDjgLH6fz8L9splnKZC7OeS0eX8b0qd7+xI1u5kyxF3KA2Z0BnigES++jjWuecqV6mA== +"@tiptap/extension-hard-break@^3.20.1": + version "3.20.1" + resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-3.20.1.tgz#5cb4e0892f05f4658d948831afcc3c6121147c77" + integrity sha512-9sKpmg/IIdlLXimYWUZ3PplIRcehv4Oc7V1miTqlnAthMzjMqigDkjjgte4JZV67RdnDJTQkRw8TklCAU28Emg== -"@tiptap/extension-heading@^3.20.0", "@tiptap/extension-heading@^3.4.1": - version "3.20.0" - resolved "https://registry.yarnpkg.com/@tiptap/extension-heading/-/extension-heading-3.20.0.tgz#a16dbb625d91556399912fa110b04b2ad2dfd2e3" - integrity sha512-JgJhurnCe3eN6a0lEsNQM/46R1bcwzwWWZEFDSb1P9dR8+t1/5v7cMZWsSInpD7R4/74iJn0+M5hcXLwCmBmYA== +"@tiptap/extension-heading@^3.20.1", "@tiptap/extension-heading@^3.4.1": + version "3.20.1" + resolved "https://registry.yarnpkg.com/@tiptap/extension-heading/-/extension-heading-3.20.1.tgz#e626c7ad0b9906d8045372e726a9a277657f3eaa" + integrity sha512-unudyfQP6FxnyWinxvPqe/51DG91J6AaJm666RnAubgYMCgym+33kBftx4j4A6qf+ddWYbD00thMNKOnVLjAEQ== -"@tiptap/extension-horizontal-rule@^3.20.0": - version "3.20.0" - resolved "https://registry.yarnpkg.com/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.20.0.tgz#53ffe2f9b9627f27f85b02d75878583dfb044107" - integrity sha512-6uvcutFMv+9wPZgptDkbRDjAm3YVxlibmkhWD5GuaWwS9L/yUtobpI3GycujRSUZ8D3q6Q9J7LqpmQtQRTalWA== +"@tiptap/extension-horizontal-rule@^3.20.1": + version "3.20.1" + resolved "https://registry.yarnpkg.com/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.20.1.tgz#a7c53eeffce44d20106e3fd84a17dd3b65f887fd" + integrity sha512-rjFKFXNntdl0jay8oIGFvvykHlpyQTLmrH3Ag2fj3i8yh6MVvqhtaDomYQbw5sxECd5hBkL+T4n2d2DRuVw/QQ== "@tiptap/extension-image@^3.4.1": version "3.13.0" resolved "https://registry.yarnpkg.com/@tiptap/extension-image/-/extension-image-3.13.0.tgz#55edb952e86c2ebed436cd53def8b2e743d71d7e" integrity sha512-223uzLUkIa1rkK7aQK3AcIXe6LbCtmnpVb7sY5OEp+LpSaSPyXwyrZ4A0EO1o98qXG68/0B2OqMntFtA9c5Fbw== -"@tiptap/extension-italic@^3.20.0": - version "3.20.0" - resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-3.20.0.tgz#176c2080a75082d296797618c2ed84e9defc7ef9" - integrity sha512-/DhnKQF8yN8RxtuL8abZ28wd5281EaGoE2Oha35zXSOF1vNYnbyt8Ymkv/7u1BcWEWTvRPgaju0YCGXisPRLYw== +"@tiptap/extension-italic@^3.20.1": + version "3.20.1" + resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-3.20.1.tgz#9ddd60cd41c5ad4b614909ab2cab385c886c1d4b" + integrity sha512-ZYRX13Kt8tR8JOzSXirH3pRpi8x30o7LHxZY58uXBdUvr3tFzOkh03qbN523+diidSVeHP/aMd/+IrplHRkQug== -"@tiptap/extension-link@^3.20.0": - version "3.20.0" - resolved "https://registry.yarnpkg.com/@tiptap/extension-link/-/extension-link-3.20.0.tgz#b3f2d89aabb88ed1eb66925e59fe82d1d2b866e9" - integrity sha512-qI/5A+R0ZWBxo/8HxSn1uOyr7odr3xHBZ/gzOR1GUJaZqjlJxkWFX0RtXMbLKEGEvT25o345cF7b0wFznEh8qA== +"@tiptap/extension-link@^3.20.1": + version "3.20.1" + resolved "https://registry.yarnpkg.com/@tiptap/extension-link/-/extension-link-3.20.1.tgz#e9cf33ae4a30b3ceb41a1aa7f923cce9fb5809ca" + integrity sha512-oYTTIgsQMqpkSnJAuAc+UtIKMuI4lv9e1y4LfI1iYm6NkEUHhONppU59smhxHLzb3Ww7YpDffbp5IgDTAiJztA== dependencies: linkifyjs "^4.3.2" -"@tiptap/extension-list-item@^3.20.0": - version "3.20.0" - resolved "https://registry.yarnpkg.com/@tiptap/extension-list-item/-/extension-list-item-3.20.0.tgz#60ec36a3326d3bf28982b653b63f6cab5c7d9d9f" - integrity sha512-qEtjaaGPuqaFB4VpLrGDoIe9RHnckxPfu6d3rc22ap6TAHCDyRv05CEyJogqccnFceG/v5WN4znUBER8RWnWHA== +"@tiptap/extension-list-item@^3.20.1": + version "3.20.1" + resolved "https://registry.yarnpkg.com/@tiptap/extension-list-item/-/extension-list-item-3.20.1.tgz#fb48984d8fbd776dc85d39641047d3bfc103a8dd" + integrity sha512-tzgnyTW82lYJkUnadYbatwkI9dLz/OWRSWuFpQPRje/ItmFMWuQ9c9NDD8qLbXPdEYnvrgSAA+ipCD/1G0qA0Q== -"@tiptap/extension-list-keymap@^3.20.0": - version "3.20.0" - resolved "https://registry.yarnpkg.com/@tiptap/extension-list-keymap/-/extension-list-keymap-3.20.0.tgz#3e49292b0cdf0a7b6d8f08ae5acdd7014c15cdc1" - integrity sha512-Z4GvKy04Ms4cLFN+CY6wXswd36xYsT2p/YL0V89LYFMZTerOeTjFYlndzn6svqL8NV1PRT5Diw4WTTxJSmcJPA== +"@tiptap/extension-list-keymap@^3.20.1": + version "3.20.1" + resolved "https://registry.yarnpkg.com/@tiptap/extension-list-keymap/-/extension-list-keymap-3.20.1.tgz#8737d2b11e222376b12ad5d6f063ff1cf45251e6" + integrity sha512-Dr0xsQKx0XPOgDg7xqoWwfv7FFwZ3WeF3eOjqh3rDXlNHMj1v+UW5cj1HLphrsAZHTrVTn2C+VWPJkMZrSbpvQ== -"@tiptap/extension-list@^3.20.0": - version "3.20.0" - resolved "https://registry.yarnpkg.com/@tiptap/extension-list/-/extension-list-3.20.0.tgz#17d379fe34e09a9b42ab620f7ff571826485c7d5" - integrity sha512-+V0/gsVWAv+7vcY0MAe6D52LYTIicMSHw00wz3ISZgprSb2yQhJ4+4gurOnUrQ4Du3AnRQvxPROaofwxIQ66WQ== +"@tiptap/extension-list@^3.20.1": + version "3.20.1" + resolved "https://registry.yarnpkg.com/@tiptap/extension-list/-/extension-list-3.20.1.tgz#cfbfeb8b66139598501467d9b0ec889821a79bd9" + integrity sha512-euBRAn0mkV7R2VEE+AuOt3R0j9RHEMFXamPFmtvTo8IInxDClusrm6mJoDjS8gCGAXsQCRiAe1SCQBPgGbOOwg== -"@tiptap/extension-ordered-list@^3.20.0": - version "3.20.0" - resolved "https://registry.yarnpkg.com/@tiptap/extension-ordered-list/-/extension-ordered-list-3.20.0.tgz#ec449716854d496ef7c1ea9243c2c467fa5d3cb1" - integrity sha512-jVKnJvrizLk7etwBMfyoj6H2GE4M+PD4k7Bwp6Bh1ohBWtfIA1TlngdS842Mx5i1VB2e3UWIwr8ZH46gl6cwMA== +"@tiptap/extension-ordered-list@^3.20.1": + version "3.20.1" + resolved "https://registry.yarnpkg.com/@tiptap/extension-ordered-list/-/extension-ordered-list-3.20.1.tgz#749b514ac42b1315d637d5d29c692f6e2344e428" + integrity sha512-Y+3Ad7OwAdagqdYwCnbqf7/to5ypD4NnUNHA0TXRCs7cAHRA8AdgPoIcGFpaaSpV86oosNU3yfeJouYeroffog== -"@tiptap/extension-paragraph@^3.20.0": - version "3.20.0" - resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-3.20.0.tgz#b1778746021ac38894287d62d9429ac309a632ef" - integrity sha512-mM99zK4+RnEXIMCv6akfNATAs0Iija6FgyFA9J9NZ6N4o8y9QiNLLa6HjLpAC+W+VoCgQIekyoF/Q9ftxmAYDQ== +"@tiptap/extension-paragraph@^3.20.1": + version "3.20.1" + resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-3.20.1.tgz#0c2ee1cdf9f3bacf713ce4db0f47abcb393c29c8" + integrity sha512-QFrAtXNyv7JSnomMQc1nx5AnG9mMznfbYJAbdOQYVdbLtAzTfiTuNPNbQrufy5ZGtGaHxDCoaygu2QEfzaKG+Q== -"@tiptap/extension-strike@^3.20.0": - version "3.20.0" - resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-3.20.0.tgz#a1ec09a1a56aad98d6e7dc493d77547cad4403ee" - integrity sha512-0vcTZRRAiDfon3VM1mHBr9EFmTkkUXMhm0Xtdtn0bGe+sIqufyi+hUYTEw93EQOD9XNsPkrud6jzQNYpX2H3AQ== +"@tiptap/extension-strike@^3.20.1": + version "3.20.1" + resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-3.20.1.tgz#b8110518b00aa780fecb70e206ea82a12f447319" + integrity sha512-EYgyma10lpsY+rwbVQL9u+gA7hBlKLSMFH7Zgd37FSxukOjr+HE8iKPQQ+SwbGejyDsPlLT8Z5Jnuxo5Ng90Pg== "@tiptap/extension-table@^3.19.0": version "3.19.0" resolved "https://registry.yarnpkg.com/@tiptap/extension-table/-/extension-table-3.19.0.tgz#a5f9be88e319f60dc7b8df1321f95a31b20fe991" integrity sha512-Lg8DlkkDUMYE/CcGOxoCWF98B2i7VWh+AGgqlF+XWrHjhlKHfENLRXm1a0vWuyyP3NknRYILoaaZ1s7QzmXKRA== -"@tiptap/extension-text@^3.20.0": - version "3.20.0" - resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-3.20.0.tgz#bdf0ac6e0c638c9dbb99a5a2b5a07db2b8cba1de" - integrity sha512-tf8bE8tSaOEWabCzPm71xwiUhyMFKqY9jkP5af3Kr1/F45jzZFIQAYZooHI/+zCHRrgJ99MQHKHe1ZNvODrKHQ== +"@tiptap/extension-text@^3.20.1": + version "3.20.1" + resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-3.20.1.tgz#f102ee6d28961f4f62a41f589d0cebfa93483c09" + integrity sha512-7PlIbYW8UenV6NPOXHmv8IcmPGlGx6HFq66RmkJAOJRPXPkTLAiX0N8rQtzUJ6jDEHqoJpaHFEHJw0xzW1yF+A== -"@tiptap/extension-underline@^3.20.0": - version "3.20.0" - resolved "https://registry.yarnpkg.com/@tiptap/extension-underline/-/extension-underline-3.20.0.tgz#535aebafc9e30da51df3be2c2065c49539535672" - integrity sha512-LzNXuy2jwR/y+ymoUqC72TiGzbOCjioIjsDu0MNYpHuHqTWPK5aV9Mh0nbZcYFy/7fPlV1q0W139EbJeYBZEAQ== +"@tiptap/extension-underline@^3.20.1": + version "3.20.1" + resolved "https://registry.yarnpkg.com/@tiptap/extension-underline/-/extension-underline-3.20.1.tgz#a07f6631a2bc5932e9872b97c22f16c6dd792611" + integrity sha512-fmHvDKzwCgnZUwRreq8tYkb1YyEwgzZ6QQkAQ0CsCRtvRMqzerr3Duz0Als4i8voZTuGDEL3VR6nAJbLAb/wPg== -"@tiptap/extensions@^3.20.0": - version "3.20.0" - resolved "https://registry.yarnpkg.com/@tiptap/extensions/-/extensions-3.20.0.tgz#22bad09a1d861446e17e0d9732439940d80ed808" - integrity sha512-HIsXX942w3nbxEQBlMAAR/aa6qiMBEP7CsSMxaxmTIVAmW35p6yUASw6GdV1u0o3lCZjXq2OSRMTskzIqi5uLg== +"@tiptap/extensions@^3.20.1": + version "3.20.1" + resolved "https://registry.yarnpkg.com/@tiptap/extensions/-/extensions-3.20.1.tgz#c95ffd378c2417cf88879ac46e78d4633d6eabe0" + integrity sha512-JRc/v+OBH0qLTdvQ7HvHWTxGJH73QOf1MC0R8NhOX2QnAbg2mPFv1h+FjGa2gfLGuCXBdWQomjekWkUKbC4e5A== -"@tiptap/pm@^3.20.0", "@tiptap/pm@^3.4.1": - version "3.20.0" - resolved "https://registry.yarnpkg.com/@tiptap/pm/-/pm-3.20.0.tgz#d9a1b92a1cb061059977952e6ec2afe8dff67857" - integrity sha512-jn+2KnQZn+b+VXr8EFOJKsnjVNaA4diAEr6FOazupMt8W8ro1hfpYtZ25JL87Kao/WbMze55sd8M8BDXLUKu1A== +"@tiptap/pm@^3.20.1", "@tiptap/pm@^3.4.1": + version "3.20.1" + resolved "https://registry.yarnpkg.com/@tiptap/pm/-/pm-3.20.1.tgz#06827f1497c3477147908e25eb91dfa1ce1c4988" + integrity sha512-6kCiGLvpES4AxcEuOhb7HR7/xIeJWMjZlb6J7e8zpiIh5BoQc7NoRdctsnmFEjZvC19bIasccshHQ7H2zchWqw== dependencies: prosemirror-changeset "^2.3.0" prosemirror-collab "^1.3.1" @@ -2430,35 +2430,35 @@ "@tiptap/extension-bubble-menu" "^3.13.0" "@tiptap/extension-floating-menu" "^3.13.0" -"@tiptap/starter-kit@^3.20.0": - version "3.20.0" - resolved "https://registry.yarnpkg.com/@tiptap/starter-kit/-/starter-kit-3.20.0.tgz#d356f15632c52f90e8eea8b5c912cab3b01b0866" - integrity sha512-W4+1re35pDNY/7rpXVg+OKo/Fa4Gfrn08Bq3E3fzlJw6gjE3tYU8dY9x9vC2rK9pd9NOp7Af11qCFDaWpohXkw== - dependencies: - "@tiptap/core" "^3.20.0" - "@tiptap/extension-blockquote" "^3.20.0" - "@tiptap/extension-bold" "^3.20.0" - "@tiptap/extension-bullet-list" "^3.20.0" - "@tiptap/extension-code" "^3.20.0" - "@tiptap/extension-code-block" "^3.20.0" - "@tiptap/extension-document" "^3.20.0" - "@tiptap/extension-dropcursor" "^3.20.0" - "@tiptap/extension-gapcursor" "^3.20.0" - "@tiptap/extension-hard-break" "^3.20.0" - "@tiptap/extension-heading" "^3.20.0" - "@tiptap/extension-horizontal-rule" "^3.20.0" - "@tiptap/extension-italic" "^3.20.0" - "@tiptap/extension-link" "^3.20.0" - "@tiptap/extension-list" "^3.20.0" - "@tiptap/extension-list-item" "^3.20.0" - "@tiptap/extension-list-keymap" "^3.20.0" - "@tiptap/extension-ordered-list" "^3.20.0" - "@tiptap/extension-paragraph" "^3.20.0" - "@tiptap/extension-strike" "^3.20.0" - "@tiptap/extension-text" "^3.20.0" - "@tiptap/extension-underline" "^3.20.0" - "@tiptap/extensions" "^3.20.0" - "@tiptap/pm" "^3.20.0" +"@tiptap/starter-kit@^3.20.1": + version "3.20.1" + resolved "https://registry.yarnpkg.com/@tiptap/starter-kit/-/starter-kit-3.20.1.tgz#a97c2d1389f05be22742b29bbe3c6cac9fe2ec70" + integrity sha512-opqWxL/4OTEiqmVC0wsU4o3JhAf6LycJ2G/gRIZVAIFLljI9uHfpPMTFGxZ5w9IVVJaP5PJysfwW/635kKqkrw== + dependencies: + "@tiptap/core" "^3.20.1" + "@tiptap/extension-blockquote" "^3.20.1" + "@tiptap/extension-bold" "^3.20.1" + "@tiptap/extension-bullet-list" "^3.20.1" + "@tiptap/extension-code" "^3.20.1" + "@tiptap/extension-code-block" "^3.20.1" + "@tiptap/extension-document" "^3.20.1" + "@tiptap/extension-dropcursor" "^3.20.1" + "@tiptap/extension-gapcursor" "^3.20.1" + "@tiptap/extension-hard-break" "^3.20.1" + "@tiptap/extension-heading" "^3.20.1" + "@tiptap/extension-horizontal-rule" "^3.20.1" + "@tiptap/extension-italic" "^3.20.1" + "@tiptap/extension-link" "^3.20.1" + "@tiptap/extension-list" "^3.20.1" + "@tiptap/extension-list-item" "^3.20.1" + "@tiptap/extension-list-keymap" "^3.20.1" + "@tiptap/extension-ordered-list" "^3.20.1" + "@tiptap/extension-paragraph" "^3.20.1" + "@tiptap/extension-strike" "^3.20.1" + "@tiptap/extension-text" "^3.20.1" + "@tiptap/extension-underline" "^3.20.1" + "@tiptap/extensions" "^3.20.1" + "@tiptap/pm" "^3.20.1" "@trysound/sax@0.2.0": version "0.2.0" @@ -4898,12 +4898,12 @@ hyphen@^1.6.4: resolved "https://registry.yarnpkg.com/hyphen/-/hyphen-1.10.6.tgz#0e779d280e696102b97d7e42f5ca5de2cc97e274" integrity sha512-fXHXcGFTXOvZTSkPJuGOQf5Lv5T/R2itiiCVPg9LxAje5D00O0pP83yJShFq5V89Ly//Gt6acj7z8pbBr34stw== -i18next@25.8.13: - version "25.8.13" - resolved "https://registry.yarnpkg.com/i18next/-/i18next-25.8.13.tgz#1f9df59329f1706f02b2b58b5d1f75196ddb6e4a" - integrity sha512-E0vzjBY1yM+nsFrtgkjLhST2NBkirkvOVoQa0MSldhsuZ3jUge7ZNpuwG0Cfc74zwo5ZwRzg3uOgT+McBn32iA== +i18next@25.8.18: + version "25.8.18" + resolved "https://registry.yarnpkg.com/i18next/-/i18next-25.8.18.tgz#51863b65bc42e3525271f2680ebbf7d150ff53cc" + integrity sha512-lzY5X83BiL5AP77+9DydbrqkQHFN9hUzWGjqjLpPcp5ZOzuu1aSoKaU3xbBLSjWx9dAzW431y+d+aogxOZaKRA== dependencies: - "@babel/runtime" "^7.28.4" + "@babel/runtime" "^7.28.6" ignore@^5.2.0: version "5.3.2" @@ -6898,10 +6898,10 @@ react-virtualized-auto-sizer@^1.0.26: resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.26.tgz#e9470ef6a778dc4f1d5fd76305fa2d8b610c357a" integrity sha512-CblNyiNVw2o+hsa5/49NH2ogGxZ+t+3aweRvNSq7TVjDIlwk7ir4lencEg5HxHeSzwNarSkNkiu0qJSOXtxm5A== -react-virtuoso@^4.18.1: - version "4.18.1" - resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-4.18.1.tgz#3eb7078f2739a31b96c723374019e587deeb6ebc" - integrity sha512-KF474cDwaSb9+SJ380xruBB4P+yGWcVkcu26HtMqYNMTYlYbrNy8vqMkE+GpAApPPufJqgOLMoWMFG/3pJMXUA== +react-virtuoso@^4.18.3: + version "4.18.3" + resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-4.18.3.tgz#12e69600c258bc6e6bd31c2516942ef08700deac" + integrity sha512-fLz/peHAx4Eu0DLHurFEEI7Y6n5CqEoxBh04rgJM9yMuOJah2a9zWg/MUOmZLcp7zuWYorXq5+5bf3IRgkNvWg== react-window@^2.2.5: version "2.2.5" @@ -7805,10 +7805,10 @@ typescript-eslint@^8.46.0: "@typescript-eslint/typescript-estree" "8.56.0" "@typescript-eslint/utils" "8.56.0" -typescript@5.9.2: - version "5.9.2" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.2.tgz#d93450cddec5154a2d5cabe3b8102b83316fb2a6" - integrity sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A== +typescript@5.9.3: + version "5.9.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== uc.micro@^2.0.0, uc.micro@^2.1.0: version "2.1.0"