diff --git a/.github/workflows/Assign_Issue_Volunteer.yml b/.github/workflows/Assign_Issue_Volunteer.yml index fe199038b2e5..d320a5be96c1 100644 --- a/.github/workflows/Assign_Issue_Volunteer.yml +++ b/.github/workflows/Assign_Issue_Volunteer.yml @@ -1,10 +1,12 @@ --- -name: "Assign Issue to Volunteer" -on: [issue_comment] # yamllint disable-line rule:truthy +name: "Issue volunteer assignment" +on: + issue_comment: + types: [created] jobs: - build: + volunteer: runs-on: ubuntu-slim steps: - - uses: bhermann/issue-volunteer@v0.1.20 + - uses: kris6673/issue-volunteer@v0.2.0 with: GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/Node_Project_Check.yml b/.github/workflows/Node_Project_Check.yml index a1581e036833..1116a307ceb7 100644 --- a/.github/workflows/Node_Project_Check.yml +++ b/.github/workflows/Node_Project_Check.yml @@ -20,7 +20,7 @@ jobs: steps: - uses: actions/checkout@v6 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v6.2.0 + uses: actions/setup-node@v6.3.0 with: node-version: ${{ matrix.node-version }} - name: Install and Build Test diff --git a/.github/workflows/cipp_dev_build.yml b/.github/workflows/cipp_dev_build.yml index 432d9363cade..f19a6d403a92 100644 --- a/.github/workflows/cipp_dev_build.yml +++ b/.github/workflows/cipp_dev_build.yml @@ -26,7 +26,7 @@ jobs: echo "node_version=$node_sanitized_version" >> $GITHUB_OUTPUT - name: Set up Node.js - uses: actions/setup-node@v6.2.0 + uses: actions/setup-node@v6.3.0 with: node-version: ${{ steps.get_node_version.outputs.node_version }} @@ -47,7 +47,7 @@ jobs: # Upload to Azure Blob Storage - name: Azure Blob Upload - uses: LanceMcCarthy/Action-AzureBlobUpload@v3.7.0 + uses: LanceMcCarthy/Action-AzureBlobUpload@v3.8.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 5db059b438a8..bf86646e19cd 100644 --- a/.github/workflows/cipp_frontend_build.yml +++ b/.github/workflows/cipp_frontend_build.yml @@ -26,7 +26,7 @@ jobs: echo "node_version=$node_sanitized_version" >> $GITHUB_OUTPUT - name: Set up Node.js - uses: actions/setup-node@v6.2.0 + uses: actions/setup-node@v6.3.0 with: node-version: ${{ steps.get_node_version.outputs.node_version }} @@ -47,7 +47,7 @@ jobs: # Upload to Azure Blob Storage - name: Azure Blob Upload - uses: LanceMcCarthy/Action-AzureBlobUpload@v3.7.0 + uses: LanceMcCarthy/Action-AzureBlobUpload@v3.8.0 with: connection_string: ${{ secrets.AZURE_CONNECTION_STRING }} container_name: cipp diff --git a/package.json b/package.json index 11957bf4dc59..06670325843f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cipp", - "version": "10.1.2", + "version": "10.2.0", "author": "CIPP Contributors", "homepage": "https://cipp.app/", "bugs": { @@ -77,15 +77,15 @@ "numeral": "2.0.6", "prop-types": "15.8.1", "punycode": "^2.3.1", - "react": "19.2.3", - "react-apexcharts": "1.7.0", + "react": "19.2.4", + "react-apexcharts": "1.9.0", "react-beautiful-dnd": "13.1.1", "react-copy-to-clipboard": "^5.1.0", - "react-dom": "19.2.3", + "react-dom": "19.2.4", "react-dropzone": "14.3.8", - "react-error-boundary": "^6.1.0", - "react-grid-layout": "^1.5.0", - "react-hook-form": "^7.71.1", + "react-error-boundary": "^6.1.1", + "react-grid-layout": "^2.2.2", + "react-hook-form": "^7.71.2", "react-hot-toast": "2.6.0", "react-html-parser": "^2.0.2", "react-i18next": "16.2.4", @@ -118,4 +118,4 @@ "eslint": "9.39.2", "eslint-config-next": "16.1.6" } -} +} \ No newline at end of file diff --git a/public/version.json b/public/version.json index 22ac50c109c0..7e488263a60c 100644 --- a/public/version.json +++ b/public/version.json @@ -1,3 +1,3 @@ { - "version": "10.1.2" + "version": "10.2.0" } \ No newline at end of file diff --git a/src/components/CippCards/CippUniversalSearchV2.jsx b/src/components/CippCards/CippUniversalSearchV2.jsx index bc6fe4055dbb..0f0d6e88ad8b 100644 --- a/src/components/CippCards/CippUniversalSearchV2.jsx +++ b/src/components/CippCards/CippUniversalSearchV2.jsx @@ -10,27 +10,32 @@ import { CircularProgress, InputAdornment, Portal, + Button, } from "@mui/material"; import { Search as SearchIcon } from "@mui/icons-material"; import { ApiGetCall } from "../../api/ApiCall"; -import { useSettings } from "../../hooks/use-settings"; import { useRouter } from "next/router"; import { BulkActionsMenu } from "../bulk-actions-menu"; -import { Button } from "@mui/material"; +import { CippOffCanvas } from "../CippComponents/CippOffCanvas"; +import { CippBitlockerKeySearch } from "../CippComponents/CippBitlockerKeySearch"; export const CippUniversalSearchV2 = React.forwardRef( ({ onConfirm = () => {}, onChange = () => {}, maxResults = 10, value = "" }, ref) => { const [searchValue, setSearchValue] = useState(value); const [searchType, setSearchType] = useState("Users"); + const [bitlockerLookupType, setBitlockerLookupType] = useState("keyId"); const [showDropdown, setShowDropdown] = useState(false); + const [bitlockerDrawerVisible, setBitlockerDrawerVisible] = useState(false); + const [bitlockerDrawerDefaults, setBitlockerDrawerDefaults] = useState({ + searchTerm: "", + searchType: "keyId", + }); const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 }); const containerRef = useRef(null); const textFieldRef = useRef(null); const router = useRouter(); - const settings = useSettings(); - const { currentTenant } = settings; - const search = ApiGetCall({ + const universalSearch = ApiGetCall({ url: `/api/ExecUniversalSearchV2`, data: { searchTerms: searchValue, @@ -41,6 +46,17 @@ export const CippUniversalSearchV2 = React.forwardRef( waiting: false, }); + const bitlockerSearch = ApiGetCall({ + url: "/api/ExecBitlockerSearch", + data: { + [bitlockerLookupType]: searchValue, + }, + queryKey: `bitlocker-universal-${bitlockerLookupType}-${searchValue}`, + waiting: false, + }); + + const activeSearch = searchType === "BitLocker" ? bitlockerSearch : universalSearch; + const handleChange = (event) => { const newValue = event.target.value; setSearchValue(newValue); @@ -71,7 +87,7 @@ export const CippUniversalSearchV2 = React.forwardRef( const handleSearch = () => { if (searchValue.length > 0) { updateDropdownPosition(); - search.refetch(); + activeSearch.refetch(); setShowDropdown(true); } }; @@ -93,6 +109,21 @@ export const CippUniversalSearchV2 = React.forwardRef( const handleTypeChange = (type) => { setSearchType(type); + if (type === "BitLocker") { + setBitlockerLookupType("keyId"); + } + setShowDropdown(false); + }; + + const handleBitlockerResultClick = (match) => { + setBitlockerDrawerDefaults({ + searchTerm: + bitlockerLookupType === "deviceId" + ? match?.deviceId || searchValue + : match?.keyId || searchValue, + searchType: bitlockerLookupType, + }); + setBitlockerDrawerVisible(true); setShowDropdown(false); }; @@ -107,6 +138,24 @@ export const CippUniversalSearchV2 = React.forwardRef( icon: "Group", onClick: () => handleTypeChange("Groups"), }, + { + label: "BitLocker", + icon: "FilePresent", + onClick: () => handleTypeChange("BitLocker"), + }, + ]; + + const bitlockerLookupActions = [ + { + label: "Key ID", + icon: "FilePresent", + onClick: () => setBitlockerLookupType("keyId"), + }, + { + label: "Device ID", + icon: "Laptop", + onClick: () => setBitlockerLookupType("deviceId"), + }, ]; // Close dropdown when clicking outside @@ -144,7 +193,12 @@ export const CippUniversalSearchV2 = React.forwardRef( } }, [showDropdown]); - const hasResults = Array.isArray(search?.data) && search.data.length > 0; + const bitlockerResults = Array.isArray(bitlockerSearch?.data?.Results) + ? bitlockerSearch.data.Results + : []; + const universalResults = Array.isArray(universalSearch?.data) ? universalSearch.data : []; + const hasResults = + searchType === "BitLocker" ? bitlockerResults.length > 0 : universalResults.length > 0; const shouldShowDropdown = showDropdown && searchValue.length > 0; const getLabel = () => { @@ -152,6 +206,10 @@ export const CippUniversalSearchV2 = React.forwardRef( return "Search users by UPN or Display Name"; } else if (searchType === "Groups") { return "Search groups by Display Name"; + } else if (searchType === "BitLocker") { + return bitlockerLookupType === "deviceId" + ? "Search BitLocker by Device ID" + : "Search BitLocker by Recovery Key ID"; } return "Search"; }; @@ -163,6 +221,12 @@ export const CippUniversalSearchV2 = React.forwardRef( buttonName={searchType} actions={typeMenuActions} /> + {searchType === "BitLocker" && ( + + )} { textFieldRef.current = node; @@ -187,7 +251,7 @@ export const CippUniversalSearchV2 = React.forwardRef( ), - endAdornment: search.isFetching ? ( + endAdornment: activeSearch.isFetching ? ( @@ -203,7 +267,7 @@ export const CippUniversalSearchV2 = React.forwardRef( - - - - } - > - - - Vacation mode adds scheduled tasks to add and remove users from Conditional Access (CA) - exclusions for a specific period of time. Select the CA policy and the date range. If - the CA policy targets a named location, you now have the ability to exclude the targeted - users from location-based audit log alerts. - - - Note: Vacation mode has recently been updated to use Group based exclusions for better - reliability. Existing vacation mode entries will continue to function as before, but it - is recommended to recreate them to take advantage of the new functionality. The - exclusion group follows the format: 'Vacation Exclusion - $Policy.displayName' - - - - - - - - - - {/* User Selector */} - - - - - {/* Conditional Access Policy Selector */} - - `${option.displayName}`, - valueField: "id", - showRefresh: true, - } - : null - } - multiple={false} - formControl={formControl} - validators={{ - validate: (option) => { - if (!option?.value) { - return "Picking a policy is required"; - } - return true; - }, - }} - required={true} - disabled={!tenantDomain} - /> - - - {/* Start Date Picker */} - - { - if (!value) { - return "Start date is required"; - } - return true; - }, - }} - /> - - - {/* End Date Picker */} - - { - const startDate = formControl.getValues("startDate"); - if (!value) { - return "End date is required"; - } - if (startDate && value && new Date(value * 1000) < new Date(startDate * 1000)) { - return "End date must be after start date"; - } - return true; - }, - }} - /> - - - {/* Post Execution Actions */} - - - - - - - - {policyHasLocationTarget && ( - - - - )} - - policy.id === selectedPolicy?.value - )[0] || {} - } - title="Selected Policy JSON" - /> - - - - - - ); -}; diff --git a/src/components/CippComponents/CippApiDialog.jsx b/src/components/CippComponents/CippApiDialog.jsx index be3ce1cb2166..4ba2060a9410 100644 --- a/src/components/CippComponents/CippApiDialog.jsx +++ b/src/components/CippComponents/CippApiDialog.jsx @@ -269,10 +269,14 @@ export const CippApiDialog = (props) => { return div.innerHTML; }; - const getNestedValue = (obj, path) => { - const value = path + const getRawNestedValue = (obj, path) => { + return path .split(".") .reduce((acc, key) => (acc && acc[key] !== undefined ? acc[key] : undefined), obj); + }; + + const getNestedValue = (obj, path) => { + const value = getRawNestedValue(obj, path); return typeof value === "string" ? escapeHtml(value) : value; }; @@ -288,7 +292,7 @@ export const CippApiDialog = (props) => { linkOpenedRef.current = true; const linkWithData = api.link.replace( /\[([^\]]+)\]/g, - (_, key) => getNestedValue(row, key) || `[${key}]`, + (_, key) => getRawNestedValue(row, key) || `[${key}]`, ); if (linkWithData.startsWith("/") && !api?.external) { router.push(linkWithData, undefined, { shallow: true }); diff --git a/src/components/CippComponents/CippApiResults.jsx b/src/components/CippComponents/CippApiResults.jsx index 124f4282e1af..777fc9d07283 100644 --- a/src/components/CippComponents/CippApiResults.jsx +++ b/src/components/CippComponents/CippApiResults.jsx @@ -198,7 +198,7 @@ export const CippApiResults = (props) => { severity: res.severity, visible: true, ...res, - })) + })), ); } else { setFinalResults([]); @@ -229,7 +229,7 @@ export const CippApiResults = (props) => { const headers = Object.keys(finalResults[0]); const rows = finalResults.map((item) => - headers.map((header) => `"${item[header] || ""}"`).join(",") + headers.map((header) => `"${item[header] || ""}"`).join(","), ); const csvContent = [headers.join(","), ...rows].join("\n"); const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); @@ -303,7 +303,7 @@ export const CippApiResults = (props) => { startIcon={} onClick={() => { const searchUrl = `https://docs.cipp.app/?q=Help+with:+${encodeURIComponent( - resultObj.copyField || resultObj.text + resultObj.copyField || resultObj.text, )}&ask=true`; window.open(searchUrl, "_blank"); }} @@ -374,6 +374,7 @@ export const CippApiResults = (props) => { language={typeof resultObj.details === "object" ? "json" : "text"} showLineNumbers={false} type="syntax" + readOnly={true} /> diff --git a/src/components/CippComponents/CippAutocomplete.jsx b/src/components/CippComponents/CippAutocomplete.jsx index d09551f1f316..a27bbe535e8a 100644 --- a/src/components/CippComponents/CippAutocomplete.jsx +++ b/src/components/CippComponents/CippAutocomplete.jsx @@ -10,7 +10,7 @@ import { Typography, } from "@mui/material"; import Link from "next/link"; -import { useEffect, useState, useMemo, useCallback, useRef } from "react"; +import { useEffect, useState, useMemo, useCallback, useRef, useImperativeHandle } from "react"; import { useSettings } from "../../hooks/use-settings"; import { getCippError } from "../../utils/get-cipp-error"; import { ApiGetCallWithPagination } from "../../api/ApiCall"; @@ -57,7 +57,7 @@ const MemoTextField = React.memo(function MemoTextField({ ); }); -export const CippAutoComplete = (props) => { +export const CippAutoComplete = React.forwardRef((props, ref) => { const { size, api, @@ -82,6 +82,7 @@ export const CippAutoComplete = (props) => { groupBy, renderGroup, customAction, + handleHomeEndKeys = false, ...other } = props; @@ -89,6 +90,14 @@ export const CippAutoComplete = (props) => { const [getRequestInfo, setGetRequestInfo] = useState({ url: "", waiting: false, queryKey: "" }); const hasPreselectedRef = useRef(false); const autocompleteRef = useRef(null); // Ref for focusing input after selection + + useImperativeHandle(ref, () => ({ + focus() { + const input = autocompleteRef.current?.querySelector("input"); + input?.focus(); + input?.select(); + }, + }), []); const listboxRef = useRef(null); // Ref for the listbox to preserve scroll position const scrollPositionRef = useRef(0); // Store scroll position const filter = createFilterOptions({ @@ -311,6 +320,7 @@ export const CippAutoComplete = (props) => { setOpen(true)} onClose={(event, reason) => { @@ -422,7 +432,7 @@ export const CippAutoComplete = (props) => { if (input) { input.focus(); } - + // Restore the scroll position if (listboxRef.current && scrollPositionRef.current > 0) { listboxRef.current.scrollTop = scrollPositionRef.current; @@ -680,4 +690,5 @@ export const CippAutoComplete = (props) => { )} ); -}; +}); +CippAutoComplete.displayName = "CippAutoComplete"; diff --git a/src/components/CippComponents/CippBitlockerKeySearch.jsx b/src/components/CippComponents/CippBitlockerKeySearch.jsx new file mode 100644 index 000000000000..90c643e1dd74 --- /dev/null +++ b/src/components/CippComponents/CippBitlockerKeySearch.jsx @@ -0,0 +1,288 @@ +import { useEffect, useRef, useState } from "react"; +import { + Button, + Box, + Typography, + Skeleton, + Grid, + Paper, + Divider, + Chip, + Alert, + CircularProgress, +} from "@mui/material"; +import { VpnKey, Computer, CheckCircle, Cancel, Info, Key } from "@mui/icons-material"; +import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; +import { CippCopyToClipBoard } from "./CippCopyToClipboard"; + +const getVolumeTypeLabel = (volumeType) => { + const types = { + 0: "Operating System Volume", + 1: "Fixed Data Volume", + 2: "Removable Data Volume", + 3: "Unknown", + }; + return types[volumeType] || `Type ${volumeType}`; +}; + +export const CippBitlockerKeySearch = ({ + initialSearchTerm = "", + initialSearchType = "keyId", + autoSearch = false, +}) => { + const searchTerm = initialSearchTerm; + const searchType = initialSearchType || "keyId"; + const hasAutoSearched = useRef(false); + + // State to store retrieved recovery keys by keyId + const [recoveryKeys, setRecoveryKeys] = useState({}); + const [loadingKeys, setLoadingKeys] = useState({}); + + const retrieveKeyMutation = ApiPostCall({}); + + const handleRetrieveKey = async (keyId, deviceId, tenant) => { + setLoadingKeys((prev) => ({ ...prev, [keyId]: true })); + + try { + const response = await retrieveKeyMutation.mutateAsync({ + url: "/api/ExecGetRecoveryKey", + data: { + GUID: deviceId, + RecoveryKeyType: "BitLocker", + tenantFilter: tenant, + }, + }); + + // Extract the key from the response + if (response?.data?.Results?.copyField) { + setRecoveryKeys((prev) => ({ ...prev, [keyId]: response.data.Results.copyField })); + } + } catch (error) { + console.error("Failed to retrieve key:", error); + } finally { + setLoadingKeys((prev) => ({ ...prev, [keyId]: false })); + } + }; + + const getBitlockerKeys = ApiGetCall({ + url: "/api/ExecBitlockerSearch", + data: { [searchType]: searchTerm }, + queryKey: `bitlocker-${searchType}-${searchTerm}`, + waiting: false, + }); + const { data, isSuccess, isFetching, refetch } = getBitlockerKeys; + const isLoading = isFetching; + + useEffect(() => { + hasAutoSearched.current = false; + }, [initialSearchTerm, initialSearchType]); + + useEffect(() => { + if (autoSearch && searchTerm && !hasAutoSearched.current) { + refetch(); + hasAutoSearched.current = true; + } + }, [autoSearch, refetch, searchTerm]); + + const results = data?.Results || []; + + const content = ( + + {isLoading && ( + + + + )} + + {isSuccess && ( + <> + + {results.map((result, index) => ( + + + {/* BitLocker Key Information */} + + + + BitLocker Key Information + + + + + + Key ID + + + + {result.keyId || "N/A"} + + + + + + + Volume Type + + + + + + + Created + + + {result.createdDateTime + ? new Date(result.createdDateTime).toLocaleString() + : "N/A"} + + + + + + Tenant + + {result.tenant || "N/A"} + + + + + Recovery Key + + + {recoveryKeys[result.keyId] ? ( + <> + + {recoveryKeys[result.keyId]} + + + + ) : ( + + )} + + + + {/* Device Information */} + {result.deviceFound && ( + <> + + + + + Device Information + + + + + + Device Name + + {result.deviceName || "N/A"} + + + + + Device ID + + + + {result.deviceId || "N/A"} + + + + + + + Operating System + + + {result.operatingSystem || "N/A"} + {result.osVersion && ` (${result.osVersion})`} + + + + + + Account Status + + + ) : ( + + ) + } + label={result.accountEnabled ? "Enabled" : "Disabled"} + size="small" + color={result.accountEnabled ? "success" : "default"} + /> + + + + + Trust Type + + {result.trustType || "N/A"} + + + + + Last Sign In + + + {result.lastSignIn ? new Date(result.lastSignIn).toLocaleString() : "N/A"} + + + + )} + + {!result.deviceFound && ( + + }> + Device information not found in cache. The device may have been deleted or + not yet synced. + + + )} + + + ))} + + + )} + + ); + return content; +}; + +export default CippBitlockerKeySearch; diff --git a/src/components/CippComponents/CippBulkInviteGuestDrawer.jsx b/src/components/CippComponents/CippBulkInviteGuestDrawer.jsx index f09d8d7bd1a7..d9d413888246 100644 --- a/src/components/CippComponents/CippBulkInviteGuestDrawer.jsx +++ b/src/components/CippComponents/CippBulkInviteGuestDrawer.jsx @@ -9,6 +9,7 @@ import { CippDataTable } from "../CippTable/CippDataTable"; import { CippApiResults } from "./CippApiResults"; import { useSettings } from "../../hooks/use-settings"; import { ApiPostCall } from "../../api/ApiCall"; +import { getCippValidator } from "../../utils/get-cipp-validator"; export const CippBulkInviteGuestDrawer = ({ buttonText = "Bulk Invite Guests", @@ -22,7 +23,7 @@ export const CippBulkInviteGuestDrawer = ({ const fields = ["displayName", "mail", "redirectUri"]; const formControl = useForm({ - mode: "onChange", + mode: "onBlur", defaultValues: { tenantFilter: initialState.currentTenant, sendInvite: true, @@ -255,6 +256,10 @@ export const CippBulkInviteGuestDrawer = ({ label="E-mail Address" type="textField" formControl={formControl} + validators={{ + required: "E-mail address is required", + validate: (value) => !value || getCippValidator(value, "email"), + }} /> diff --git a/src/components/CippComponents/CippBulkUserDrawer.jsx b/src/components/CippComponents/CippBulkUserDrawer.jsx index 6fd62b106c38..53d4317c07be 100644 --- a/src/components/CippComponents/CippBulkUserDrawer.jsx +++ b/src/components/CippComponents/CippBulkUserDrawer.jsx @@ -43,8 +43,34 @@ export const CippBulkUserDrawer = ({ ...addedFields, ]; + const fieldValidators = { + givenName: { maxLength: { value: 64, message: "First Name cannot exceed 64 characters" } }, + surName: { maxLength: { value: 64, message: "Last Name cannot exceed 64 characters" } }, + displayName: { + required: "Display Name is required", + maxLength: { value: 256, message: "Display Name cannot exceed 256 characters" }, + }, + mailNickName: { + required: "Username is required", + maxLength: { value: 64, message: "Username cannot exceed 64 characters" }, + pattern: { + value: /^[A-Za-z0-9'.\-_!#^~]+$/, + message: "Username can only contain letters, numbers, and ' . - _ ! # ^ ~ characters", + }, + }, + JobTitle: { maxLength: { value: 128, message: "Job Title cannot exceed 128 characters" } }, + streetAddress: { + maxLength: { value: 1024, message: "Street Address cannot exceed 1024 characters" }, + }, + PostalCode: { maxLength: { value: 40, message: "Postal Code cannot exceed 40 characters" } }, + City: { maxLength: { value: 128, message: "City cannot exceed 128 characters" } }, + State: { maxLength: { value: 128, message: "State/Province cannot exceed 128 characters" } }, + Department: { maxLength: { value: 64, message: "Department cannot exceed 64 characters" } }, + MobilePhone: { maxLength: { value: 64, message: "Mobile # cannot exceed 64 characters" } }, + }; + const formControl = useForm({ - mode: "onChange", + mode: "onBlur", defaultValues: { tenantFilter: initialState.currentTenant, usageLocation: initialState.usageLocation || "US", @@ -141,8 +167,8 @@ export const CippBulkUserDrawer = ({ {createBulkUsers.isLoading ? "Creating Users..." : createBulkUsers.isSuccess - ? "Create More Users" - : "Create Users"} + ? "Create More Users" + : "Create Users"} + + + + + + + {categories.map((cat) => { + const items = Array.isArray(backupData) + ? backupData.filter((item) => getItemCategoryKey(item) === cat.key) + : []; + const isExpanded = !!expandedCategories[cat.key]; + return ( + + + handleToggleCategory(cat.key)} + size="small" + /> + } + label={ + + + {cat.label} + + + + } + /> + + handleToggleExpand(cat.key)}> + {isExpanded ? ( + + ) : ( + + )} + + + + + (theme.palette.mode === "dark" ? "grey.900" : "grey.50"), + borderRadius: 1, + border: (theme) => `1px solid ${theme.palette.divider}`, + }} + > + {items.map((item, i) => { + const name = getItemDisplayName(item); + const sub = + name !== item.RowKey && item.RowKey + ? item.RowKey.replace(/\.json$/, "") + : null; + return ( + + + {name} + + {sub && ( + + {sub} + + )} + + ); + })} + + + + ); + })} + + + + ); + + // Step 2 — Confirm and results + const StepConfirm = () => ( + + {!restoreAction.isSuccess && ( + }> + Confirm Restore + This will overwrite your current CIPP configuration for the selected categories. This + action cannot be undone. + + )} + + + {selectedCount} {selectedCount === 1 ? "category" : "categories"} ({filteredData.length}{" "} + items) selected for restore: + + + {categories + .filter((c) => selectedCategories[c.key]) + .map((c) => ( + + ))} + + + + + ); + + const StepComponents = [StepValidation, StepSelectCategories, StepConfirm]; + const CurrentStep = StepComponents[step]; + + const canProceed = + step === 0 ? validationResult?.isValid : step === 1 ? selectedCount > 0 : false; + + return ( + + + + Restore Backup + + {!isLoading && ( + <> + + + + {WIZARD_STEPS.map((label) => ( + + {label} + + ))} + + + + )} + + {isLoading ? ( + + + + Loading backup… + + + ) : ( + + )} + + + + {!isLoading && step > 0 && !restoreAction.isSuccess && ( + + )} + {!isLoading && step < WIZARD_STEPS.length - 1 && ( + + )} + {!isLoading && step === WIZARD_STEPS.length - 1 && !restoreAction.isSuccess && ( + + )} + + + ); +}; + +export default CippRestoreWizard; diff --git a/src/components/CippComponents/CippReusableSettingsDeployDrawer.jsx b/src/components/CippComponents/CippReusableSettingsDeployDrawer.jsx index 06365e32d50c..007de36b32f1 100644 --- a/src/components/CippComponents/CippReusableSettingsDeployDrawer.jsx +++ b/src/components/CippComponents/CippReusableSettingsDeployDrawer.jsx @@ -27,11 +27,14 @@ export const CippReusableSettingsDeployDrawer = ({ const templates = ApiGetCall({ url: "/api/ListIntuneReusableSettingTemplates", queryKey: "ListIntuneReusableSettingTemplates" }); + const getRawJson = (source) => source?.RawJSON ?? source?.RAWJson ?? source?.rawJSON ?? ""; + useEffect(() => { if (templates.isSuccess && selectedTemplate?.value) { const match = templates.data?.find((t) => t.GUID === selectedTemplate.value); if (match) { - formControl.setValue("rawJSON", match.RawJSON || ""); + const rawJsonValue = getRawJson(match); + formControl.setValue("rawJSON", rawJsonValue); formControl.setValue("TemplateId", match.GUID); } } diff --git a/src/components/CippComponents/CippSettingsSideBar.jsx b/src/components/CippComponents/CippSettingsSideBar.jsx index 693c55673d77..c88654017e46 100644 --- a/src/components/CippComponents/CippSettingsSideBar.jsx +++ b/src/components/CippComponents/CippSettingsSideBar.jsx @@ -137,6 +137,9 @@ export const CippSettingsSideBar = (props) => { Settings on this page can be saved for the current user, or all users. Select the desired option below. + + Navigation settings are per-device and stored locally, regardless of the user selector. + { }; const activeSponsors = getActiveSponsors(); -const selectedSponsor = selectRandomSponsor(activeSponsors); export const CippSponsor = () => { + const pathname = usePathname(); + const [selectedSponsor, setSelectedSponsor] = useState(() => selectRandomSponsor(activeSponsors)); const currentSettings = useSettings(); const theme = currentSettings?.currentTheme?.value; + useEffect(() => { + setSelectedSponsor(selectRandomSponsor(activeSponsors)); + }, [pathname]); + // Get the appropriate image based on current theme const randomimg = useMemo(() => { if (!selectedSponsor) return null; @@ -53,7 +59,7 @@ export const CippSponsor = () => { altText: selectedSponsor.altText, tooltip: selectedSponsor.tooltip, }; - }, [theme]); + }, [selectedSponsor, theme]); // Don't render if no sponsors are available if (!randomimg) { diff --git a/src/components/CippComponents/CippTenantSelector.jsx b/src/components/CippComponents/CippTenantSelector.jsx index ab9e50ea8fcf..f215f9879006 100644 --- a/src/components/CippComponents/CippTenantSelector.jsx +++ b/src/components/CippComponents/CippTenantSelector.jsx @@ -19,14 +19,14 @@ import { ServerIcon, UsersIcon, } from "@heroicons/react/24/outline"; -import { useEffect, useState, useMemo, useCallback, useRef } from "react"; +import React, { useEffect, useState, useMemo, useCallback, useRef } from "react"; import { useRouter } from "next/router"; import { CippOffCanvas } from "./CippOffCanvas"; import { useSettings } from "../../hooks/use-settings"; import { getCippError } from "../../utils/get-cipp-error"; import { useQueryClient } from "@tanstack/react-query"; -export const CippTenantSelector = (props) => { +export const CippTenantSelector = React.forwardRef((props, ref) => { const { width, allTenants = false, multiple = false, refreshButton, tenantButton } = props; //get the current tenant from SearchParams called 'tenantFilter' const router = useRouter(); @@ -325,6 +325,7 @@ export const CippTenantSelector = (props) => { )} { /> ); -}; +}); + +CippTenantSelector.displayName = "CippTenantSelector"; CippTenantSelector.propTypes = { allTenants: PropTypes.bool, diff --git a/src/components/CippComponents/CippTransportRuleDrawer.jsx b/src/components/CippComponents/CippTransportRuleDrawer.jsx index 54d69cd258f5..37e4bcf308fb 100644 --- a/src/components/CippComponents/CippTransportRuleDrawer.jsx +++ b/src/components/CippComponents/CippTransportRuleDrawer.jsx @@ -34,6 +34,13 @@ export const CippTransportRuleDrawer = ({ waiting: !!drawerVisible || !!isEditMode || !!ruleId, }); + // Fetch all rules for priority suggestion in create mode (shares cache key with list page) + const allRulesInfo = ApiGetCall({ + url: `/api/ListTransportRules?tenantFilter=${currentTenant}`, + queryKey: `List Transport Rules For Priority - ${currentTenant}`, + waiting: !!drawerVisible, + }); + // Default form values const defaultFormValues = useMemo( () => ({ @@ -116,7 +123,13 @@ export const CippTransportRuleDrawer = ({ FromAddressMatchesPatterns: "Sender address matches patterns...", AttachmentContainsWords: "Attachment content contains words...", AttachmentMatchesPatterns: "Attachment content matches patterns...", + AttachmentNameMatchesPatterns: "Attachment name matches patterns...", + AttachmentPropertyContainsWords: "Attachment properties contain words...", AttachmentExtensionMatchesWords: "Attachment extension is...", + AttachmentHasExecutableContent: "Attachment has executable content", + AttachmentIsPasswordProtected: "Attachment is password protected", + AttachmentIsUnsupported: "Attachment type is unsupported", + AttachmentProcessingLimitExceeded: "Attachment processing limit exceeded", AttachmentSizeOver: "Attachment size is greater than...", MessageSizeOver: "Message size is greater than...", SCLOver: "SCL is greater than or equal to...", @@ -290,6 +303,15 @@ export const CippTransportRuleDrawer = ({ if (rule.ApplyHtmlDisclaimerFallbackAction) { formData.ApplyHtmlDisclaimerFallbackAction = { value: rule.ApplyHtmlDisclaimerFallbackAction, label: rule.ApplyHtmlDisclaimerFallbackAction }; } + if (rule.IncidentReportContent) { + const incidentReportContentValues = Array.isArray(rule.IncidentReportContent) + ? rule.IncidentReportContent + : rule.IncidentReportContent + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + formData.IncidentReportContent = incidentReportContentValues.map((item) => ({ value: item, label: item })); + } Object.keys(actionFieldMap).forEach(field => { if (rule[field] !== null && rule[field] !== undefined && !formData[field]) { @@ -371,6 +393,28 @@ export const CippTransportRuleDrawer = ({ } }, [resetForm, drawerVisible, isEditMode]); + useEffect(() => { + if (!drawerVisible || isEditMode || !Array.isArray(allRulesInfo.data?.Results)) { + return; + } + + const priorities = allRulesInfo.data.Results + .map((rule) => Number(rule?.Priority)) + .filter((priority) => Number.isFinite(priority)); + + if (!priorities.length) { + return; + } + + const currentPriority = formControl.getValues("Priority"); + if (currentPriority === "" || currentPriority === null || currentPriority === undefined) { + formControl.setValue("Priority", Math.max(...priorities) + 1, { + shouldDirty: false, + shouldTouch: false, + }); + } + }, [drawerVisible, isEditMode, allRulesInfo.data, formControl]); + // Custom data formatter for API submission const customDataFormatter = useCallback( (values) => { @@ -456,6 +500,26 @@ export const CippTransportRuleDrawer = ({ const fallback = values.ApplyHtmlDisclaimerFallbackAction; apiData.ApplyHtmlDisclaimerFallbackAction = fallback?.value || fallback; } + } else if (actionValue === "GenerateIncidentReport") { + if (values.GenerateIncidentReport !== undefined) { + const fieldValue = values.GenerateIncidentReport; + apiData.GenerateIncidentReport = + fieldValue && typeof fieldValue === "object" && fieldValue.value !== undefined + ? fieldValue.value + : fieldValue; + } + if (values.IncidentReportContent !== undefined) { + const fieldValue = values.IncidentReportContent; + const incidentReportValues = Array.isArray(fieldValue) + ? fieldValue.map((item) => { + if (item && typeof item === "object" && item.value !== undefined) { + return item.value; + } + return item; + }) + : [fieldValue]; + apiData.IncidentReportContent = incidentReportValues.filter(Boolean).join(","); + } } else if (values[actionValue] !== undefined) { const fieldValue = values[actionValue]; @@ -642,7 +706,13 @@ export const CippTransportRuleDrawer = ({ { value: "FromAddressMatchesPatterns", label: "Sender address matches patterns..." }, { value: "AttachmentContainsWords", label: "Attachment content contains words..." }, { value: "AttachmentMatchesPatterns", label: "Attachment content matches patterns..." }, + { value: "AttachmentNameMatchesPatterns", label: "Attachment name matches patterns..." }, + { value: "AttachmentPropertyContainsWords", label: "Attachment properties contain words..." }, { value: "AttachmentExtensionMatchesWords", label: "Attachment extension is..." }, + { value: "AttachmentHasExecutableContent", label: "Attachment has executable content" }, + { value: "AttachmentIsPasswordProtected", label: "Attachment is password protected" }, + { value: "AttachmentIsUnsupported", label: "Attachment type is unsupported" }, + { value: "AttachmentProcessingLimitExceeded", label: "Attachment processing limit exceeded" }, { value: "AttachmentSizeOver", label: "Attachment size is greater than..." }, { value: "MessageSizeOver", label: "Message size is greater than..." }, { value: "SCLOver", label: "SCL is greater than or equal to..." }, @@ -686,6 +756,18 @@ export const CippTransportRuleDrawer = ({ { value: "GenerateNotification", label: "Notify the sender with a message..." }, { value: "ApplyOME", label: "Apply Office 365 Message Encryption" }, ]; + const incidentReportContentOptions = [ + { value: "Sender", label: "Sender" }, + { value: "Recipients", label: "Recipients" }, + { value: "Subject", label: "Subject" }, + { value: "CC", label: "CC" }, + { value: "BCC", label: "BCC" }, + { value: "Severity", label: "Severity" }, + { value: "RuleDetections", label: "RuleDetections" }, + { value: "FalsePositive", label: "FalsePositive" }, + { value: "IdMatch", label: "IdMatch" }, + { value: "AttachOriginalMail", label: "AttachOriginalMail" }, + ]; const renderConditionField = (condition) => { const conditionValue = condition.value || condition; @@ -850,6 +932,35 @@ export const CippTransportRuleDrawer = ({ ); + case "AttachmentHasExecutableContent": + case "AttachmentIsPasswordProtected": + case "AttachmentIsUnsupported": + case "AttachmentProcessingLimitExceeded": + return ( + + + + ); + + case "AttachmentNameMatchesPatterns": + case "AttachmentPropertyContainsWords": + return ( + + + + ); + case "SenderDomainIs": case "RecipientDomainIs": return ( @@ -945,7 +1056,6 @@ export const CippTransportRuleDrawer = ({ case "BlindCopyTo": case "CopyTo": case "ModerateMessageByUser": - case "GenerateIncidentReport": return ( ); + case "GenerateIncidentReport": + return ( + + + + `${option.displayName} (${option.userPrincipalName})`, + valueField: "userPrincipalName", + dataKey: "Results", + }} + /> + + + + + + + ); + case "RouteMessageOutboundConnector": return ( diff --git a/src/components/CippComponents/MailboxRestoreDetails.jsx b/src/components/CippComponents/MailboxRestoreDetails.jsx index 23feefa76f76..cd3eedff4091 100644 --- a/src/components/CippComponents/MailboxRestoreDetails.jsx +++ b/src/components/CippComponents/MailboxRestoreDetails.jsx @@ -91,6 +91,7 @@ const MailboxRestoreDetails = ({ data }) => { open={dialogOpen} onClose={() => setDialogOpen(false)} code={restoreStatistics?.data?.[0]?.Report} + readOnly={true} /> diff --git a/src/components/CippFormPages/CippAddEditUser.jsx b/src/components/CippFormPages/CippAddEditUser.jsx index da62276387a8..edcc5e7cb73c 100644 --- a/src/components/CippFormPages/CippAddEditUser.jsx +++ b/src/components/CippFormPages/CippAddEditUser.jsx @@ -1,5 +1,6 @@ import { Alert, Divider, InputAdornment, Typography } from "@mui/material"; import CippFormComponent from "../CippComponents/CippFormComponent"; +import { getCippValidator } from "../../utils/get-cipp-validator"; import { CippFormCondition } from "../CippComponents/CippFormCondition"; import { CippFormDomainSelector } from "../CippComponents/CippFormDomainSelector"; import { CippFormUserSelector } from "../CippComponents/CippFormUserSelector"; @@ -298,6 +299,9 @@ const CippAddEditUser = (props) => { label="First Name" name="givenName" formControl={formControl} + validators={{ + maxLength: { value: 64, message: "First Name cannot exceed 64 characters" }, + }} /> @@ -307,6 +311,9 @@ const CippAddEditUser = (props) => { label="Last Name" name="surname" formControl={formControl} + validators={{ + maxLength: { value: 64, message: "Last Name cannot exceed 64 characters" }, + }} /> @@ -316,6 +323,10 @@ const CippAddEditUser = (props) => { label="Display Name" name="displayName" formControl={formControl} + validators={{ + required: "Display Name is required", + maxLength: { value: 256, message: "Display Name cannot exceed 256 characters" }, + }} onChange={(e) => { setDisplayNameManuallySet(true); }} @@ -333,6 +344,14 @@ const CippAddEditUser = (props) => { }} name="username" formControl={formControl} + validators={{ + required: "Username is required", + maxLength: { value: 64, message: "Username cannot exceed 64 characters" }, + pattern: { + value: /^[A-Za-z0-9'.\-_!#^~]+$/, + message: "Username can only contain letters, numbers, and ' . - _ ! # ^ ~ characters", + }, + }} onChange={(e) => { setUsernameManuallySet(true); }} @@ -345,6 +364,7 @@ const CippAddEditUser = (props) => { formControl={formControl} name="primDomain" label="Primary Domain name" + validators={{ required: "Primary Domain is required" }} /> @@ -476,6 +496,9 @@ const CippAddEditUser = (props) => { label="Job Title" name="jobTitle" formControl={formControl} + validators={{ + maxLength: { value: 128, message: "Job Title cannot exceed 128 characters" }, + }} /> @@ -485,6 +508,9 @@ const CippAddEditUser = (props) => { label="Street" name="streetAddress" formControl={formControl} + validators={{ + maxLength: { value: 1024, message: "Street Address cannot exceed 1024 characters" }, + }} /> @@ -494,6 +520,7 @@ const CippAddEditUser = (props) => { label="City" name="city" formControl={formControl} + validators={{ maxLength: { value: 128, message: "City cannot exceed 128 characters" } }} /> @@ -503,6 +530,9 @@ const CippAddEditUser = (props) => { label="State/Province" name="state" formControl={formControl} + validators={{ + maxLength: { value: 128, message: "State/Province cannot exceed 128 characters" }, + }} /> @@ -512,6 +542,9 @@ const CippAddEditUser = (props) => { label="Postal Code" name="postalCode" formControl={formControl} + validators={{ + maxLength: { value: 40, message: "Postal Code cannot exceed 40 characters" }, + }} /> @@ -530,6 +563,9 @@ const CippAddEditUser = (props) => { label="Company Name" name="companyName" formControl={formControl} + validators={{ + maxLength: { value: 64, message: "Company Name cannot exceed 64 characters" }, + }} /> @@ -539,6 +575,9 @@ const CippAddEditUser = (props) => { label="Department" name="department" formControl={formControl} + validators={{ + maxLength: { value: 64, message: "Department cannot exceed 64 characters" }, + }} /> @@ -548,6 +587,7 @@ const CippAddEditUser = (props) => { label="Mobile #" name="mobilePhone" formControl={formControl} + validators={{ maxLength: { value: 64, message: "Mobile # cannot exceed 64 characters" } }} /> @@ -566,6 +606,7 @@ const CippAddEditUser = (props) => { label="Alternate Email Address" name="otherMails" formControl={formControl} + validators={{ validate: (value) => !value || getCippValidator(value, "email") }} /> {userSettingsDefaults?.userAttributes diff --git a/src/components/CippStandards/CippStandardDialog.jsx b/src/components/CippStandards/CippStandardDialog.jsx index 6873936d9cda..ffe959653259 100644 --- a/src/components/CippStandards/CippStandardDialog.jsx +++ b/src/components/CippStandards/CippStandardDialog.jsx @@ -646,6 +646,7 @@ const CippStandardDialog = ({ const [selectedRecommendedBy, setSelectedRecommendedBy] = useState([]); const [selectedTagFrameworks, setSelectedTagFrameworks] = useState([]); const [showOnlyNew, setShowOnlyNew] = useState(false); // Show only standards added in last 30 days + const [statusFilter, setStatusFilter] = useState("all"); // "all" | "enabled" | "disabled" const [filtersExpanded, setFiltersExpanded] = useState(false); // Control filter section collapse/expand // Auto-adjust sort order when sort type changes @@ -823,13 +824,21 @@ const CippStandardDialog = ({ }; const matchesNewFilter = !showOnlyNew || isNewStandard(standard.addedDate); + // Status filter: enabled = already in selectedStandards, disabled = not yet added + const isEnabled = !!selectedStandards[standard.name]; + const matchesStatusFilter = + statusFilter === "all" || + (statusFilter === "enabled" && isEnabled) || + (statusFilter === "disabled" && !isEnabled); + return ( matchesSearch && matchesCategory && matchesImpact && matchesRecommendedBy && matchesTagFramework && - matchesNewFilter + matchesNewFilter && + matchesStatusFilter ); }); }, @@ -840,6 +849,8 @@ const CippStandardDialog = ({ selectedRecommendedBy, selectedTagFrameworks, showOnlyNew, + statusFilter, + selectedStandards, ] ); @@ -935,6 +946,7 @@ const CippStandardDialog = ({ setSelectedRecommendedBy([]); setSelectedTagFrameworks([]); setShowOnlyNew(false); + setStatusFilter("all"); setSortBy("addedDate"); setSortOrder("desc"); setViewMode("card"); // Reset to card view @@ -949,6 +961,7 @@ const CippStandardDialog = ({ setSelectedRecommendedBy([]); setSelectedTagFrameworks([]); setShowOnlyNew(false); + setStatusFilter("all"); setViewMode("card"); // Reset to card view handleSearchQueryChange(""); // Clear parent search state handleCloseDialog(); @@ -1024,7 +1037,8 @@ const CippStandardDialog = ({ selectedImpacts.length + selectedRecommendedBy.length + selectedTagFrameworks.length + - (showOnlyNew ? 1 : 0); + (showOnlyNew ? 1 : 0) + + (statusFilter !== "all" ? 1 : 0); // Don't render dialog contents until it's actually open (improves performance) return ( @@ -1271,6 +1285,21 @@ const CippStandardDialog = ({ sx={{ ml: 1 }} /> + {/* Status Filter */} + { + if (newValue !== null) setStatusFilter(newValue); + }} + size="small" + sx={{ height: 45 }} + > + All + Enabled + Disabled + + {/* Clear Button */} {activeFiltersCount > 0 && ( + )} + {hasSubmitted ? ( + + ) : ( + + )} + + + ); +}; diff --git a/src/components/CippWizard/CippWizardVacationSchedule.jsx b/src/components/CippWizard/CippWizardVacationSchedule.jsx new file mode 100644 index 000000000000..719f194a6d07 --- /dev/null +++ b/src/components/CippWizard/CippWizardVacationSchedule.jsx @@ -0,0 +1,98 @@ +import { Stack, Typography } from "@mui/material"; +import { Grid } from "@mui/system"; +import CippWizardStepButtons from "./CippWizardStepButtons"; +import CippFormComponent from "../CippComponents/CippFormComponent"; + +export const CippWizardVacationSchedule = (props) => { + const { postUrl, formControl, onPreviousStep, onNextStep, currentStep, lastStep } = props; + + return ( + + + Set the date range for the vacation period and optional notification settings. + + + + {/* Start Date */} + + { + if (!value) { + return "Start date is required"; + } + return true; + }, + }} + /> + + + {/* End Date */} + + { + const startDate = formControl.getValues("startDate"); + if (!value) { + return "End date is required"; + } + if (startDate && value && new Date(value * 1000) < new Date(startDate * 1000)) { + return "End date must be after start date"; + } + return true; + }, + }} + /> + + + {/* Post Execution Actions */} + + + + + {/* Reference */} + + + + + + + + ); +}; diff --git a/src/components/actions-menu.js b/src/components/actions-menu.js index ff11c55d2edd..77a4c1c6a6cc 100644 --- a/src/components/actions-menu.js +++ b/src/components/actions-menu.js @@ -71,6 +71,7 @@ export const ActionsMenu = (props) => { if (action?.noConfirm && action.customFunction) { action.customFunction(data, action, {}); + popover.handleClose(); } else { createDialog.handleOpen(); popover.handleClose(); diff --git a/src/contexts/settings-context.js b/src/contexts/settings-context.js index 37bb7a1cfdd6..bbc7f8a60de5 100644 --- a/src/contexts/settings-context.js +++ b/src/contexts/settings-context.js @@ -81,6 +81,8 @@ const initialSettings = { persistFilters: false, lastUsedFilters: {}, breadcrumbMode: "hierarchical", + bookmarkSidebar: true, + bookmarkPopover: false, }; const initialState = { diff --git a/src/data/Extensions.json b/src/data/Extensions.json index 21471f5e295c..52df55dd4726 100644 --- a/src/data/Extensions.json +++ b/src/data/Extensions.json @@ -710,8 +710,8 @@ "placeholder": "Enter your email address for PWPush. (Email & API Key auth)", "condition": { "field": "PWPush.UseBearerAuth", - "compareType": "is", - "compareValue": false + "compareType": "isNot", + "compareValue": true } }, { @@ -758,10 +758,22 @@ "action": "disable" } }, + { + "type": "password", + "name": "PWPush.DefaultPassphrase", + "label": "Default Passphrase", + "placeholder": "Enter a default passphrase required to view pushed passwords. (optional)", + "condition": { + "field": "PWPush.Enabled", + "compareType": "is", + "compareValue": true, + "action": "disable" + } + }, { "type": "switch", "name": "PWPush.RetrievalStep", - "label": "Click to retrieve password (recommended)", + "label": "Click to retrieve password (recommended if passphrase is not set)", "condition": { "field": "PWPush.Enabled", "compareType": "is", @@ -779,6 +791,16 @@ "compareValue": true, "action": "disable" } + }, + { + "type": "switch", + "name": "PWPush.CFEnabled", + "label": "Behind a CF-ZTNA Tunnel", + "condition": { + "field": "CFZTNA.Enabled", + "compareType": "is", + "compareValue": true + } } ], "mappingRequired": false diff --git a/src/data/alerts.json b/src/data/alerts.json index dfdf0b7ff509..7e93eb047e8b 100644 --- a/src/data/alerts.json +++ b/src/data/alerts.json @@ -7,7 +7,11 @@ { "name": "MFAAdmins", "label": "Alert on admins without any form of MFA", - "recommendedRunInterval": "1d" + "recommendedRunInterval": "1d", + "requiresInput": true, + "inputType": "switch", + "inputLabel": "Include disabled admin accounts?", + "inputName": "IncludeDisabled" }, { "name": "NewMFADevice", diff --git a/src/data/standards.json b/src/data/standards.json index a8ebb3288a35..33d28185ebe6 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -4044,6 +4044,28 @@ "powershellEquivalent": "Update-MgBetaPolicyDeviceRegistrationPolicy", "recommendedBy": [] }, + { + "name": "standards.intuneRestrictUserDeviceRegistration", + "cat": "Entra (AAD) Standards", + "tag": [], + "helpText": "Controls whether users can register devices with Entra.", + "docsDescription": "Configures whether users can register devices with Entra. When disabled, users are unable to register devices with Entra.", + "executiveText": "Controls whether employees can register their devices for corporate access. Disabling user device registration prevents unauthorized or unmanaged devices from connecting to company resources, enhancing overall security posture.", + "addedComponent": [ + { + "type": "switch", + "name": "standards.intuneRestrictUserDeviceRegistration.disableUserDeviceRegistration", + "label": "Disable users from registering devices", + "defaultValue": true + } + ], + "label": "Configure user restriction for Entra device registration", + "impact": "High Impact", + "impactColour": "warning", + "addedDate": "2026-02-23", + "powershellEquivalent": "Update-MgBetaPolicyDeviceRegistrationPolicy", + "recommendedBy": [] + }, { "name": "standards.intuneRequireMFA", "cat": "Intune Standards", @@ -5388,6 +5410,11 @@ "name": "customGroup", "label": "Enter the custom group name if you selected 'Assign to Custom Group'. Wildcards are allowed." }, + { + "type": "switch", + "name": "verifyAssignments", + "label": "Verify policy assignments" + }, { "name": "excludeGroup", "label": "Exclude Groups", diff --git a/src/hooks/use-license-backfill.js b/src/hooks/use-license-backfill.js new file mode 100644 index 000000000000..97b52ea6315c --- /dev/null +++ b/src/hooks/use-license-backfill.js @@ -0,0 +1,42 @@ +import { useEffect, useState } from "react"; +import licenseBackfillManager from "../utils/cipp-license-backfill-manager"; + +/** + * Hook to trigger re-render when license backfill completes + * Use this in components that display licenses to automatically update + * when missing licenses are fetched from the API + * + * @returns {Object} Object containing backfill status + */ +export const useLicenseBackfill = () => { + const [updateTrigger, setUpdateTrigger] = useState(0); + const [status, setStatus] = useState(licenseBackfillManager.getStatus()); + + useEffect(() => { + // Subscribe to backfill completion events + const unsubscribe = licenseBackfillManager.addCallback(() => { + // Trigger re-render by updating state + setUpdateTrigger((prev) => prev + 1); + setStatus(licenseBackfillManager.getStatus()); + }); + + // Update status periodically while backfilling + const interval = setInterval(() => { + const currentStatus = licenseBackfillManager.getStatus(); + if (currentStatus.isBackfilling !== status.isBackfilling || + currentStatus.pendingCount !== status.pendingCount) { + setStatus(currentStatus); + } + }, 200); + + return () => { + unsubscribe(); + clearInterval(interval); + }; + }, [status.isBackfilling, status.pendingCount]); + + return { + ...status, + updateTrigger, // Can be used as a key to force re-render if needed + }; +}; diff --git a/src/layouts/account-popover.js b/src/layouts/account-popover.js index 579780b6adc8..444ee7dcd4ac 100644 --- a/src/layouts/account-popover.js +++ b/src/layouts/account-popover.js @@ -146,7 +146,7 @@ export const AccountPopover = (props) => { secondary={orgData?.data?.Org?.Domain} /> - + { popover.handleClose(); onThemeSwitch(); }}> {paletteMode === "dark" ? : } @@ -156,7 +156,7 @@ export const AccountPopover = (props) => { )} - router.push("/cipp/preferences")}> + { popover.handleClose(); router.push("/cipp/preferences"); }}> diff --git a/src/layouts/config.js b/src/layouts/config.js index 3bfc34eeb508..78a9cd4ed9d5 100644 --- a/src/layouts/config.js +++ b/src/layouts/config.js @@ -78,6 +78,11 @@ export const nativeMenuItems = [ path: "/identity/administration/jit-admin-templates", permissions: ["Identity.Role.*"], }, + { + title: "Vacation Mode", + path: "/identity/administration/vacation-mode", + permissions: ["Identity.User.*"], + }, { title: "Offboarding Wizard", path: "/identity/administration/offboarding-wizard", @@ -801,7 +806,6 @@ export const nativeMenuItems = [ path: "/tenant/tools/tenantlookup", permissions: ["Tenant.Administration.*"], }, - { title: "IP Database", path: "/tenant/tools/geoiplookup", diff --git a/src/layouts/index.js b/src/layouts/index.js index f7b6ccd27869..7a92342bd65d 100644 --- a/src/layouts/index.js +++ b/src/layouts/index.js @@ -64,6 +64,8 @@ const LayoutRoot = styled("div")(({ theme }) => ({ display: "flex", flex: "1 1 auto", maxWidth: "100%", + height: "100vh", + overflow: "hidden", paddingTop: TOP_NAV_HEIGHT, [theme.breakpoints.up("lg")]: { paddingLeft: SIDE_NAV_WIDTH, @@ -75,6 +77,8 @@ const LayoutContainer = styled("div")({ flex: "1 1 auto", flexDirection: "column", width: "100%", + overflowY: "auto", + overscrollBehavior: "contain", }); export const Layout = (props) => { @@ -224,12 +228,24 @@ export const Layout = (props) => { } // get current devtools settings var showDevtools = settings.showDevtools; - // get current bookmarks + // 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; settings.handleUpdate({ ...userSettingsAPI.data, bookmarks, + bookmarkSidebar, + bookmarkPopover, + bookmarkReorderMode, + bookmarkLocked, + bookmarkSortOrder, + bookmarksOpen, showDevtools, }); diff --git a/src/layouts/mobile-nav.js b/src/layouts/mobile-nav.js index 30612f3fd28a..537e57b6b475 100644 --- a/src/layouts/mobile-nav.js +++ b/src/layouts/mobile-nav.js @@ -1,12 +1,14 @@ import NextLink from "next/link"; import { usePathname } from "next/navigation"; import PropTypes from "prop-types"; -import { Box, Drawer, Stack } from "@mui/material"; +import { Box, Divider, Drawer, Stack } from "@mui/material"; import { Logo } from "../components/logo"; import { Scrollbar } from "../components/scrollbar"; import { paths } from "../paths"; import { MobileNavItem } from "./mobile-nav-item"; +import { SideNavBookmarks } from "./side-nav-bookmarks"; import { CippTenantSelector } from "../components/CippComponents/CippTenantSelector"; +import { useSettings } from "../hooks/use-settings"; const MOBILE_NAV_WIDTH = "80%"; @@ -77,6 +79,8 @@ const reduceChildRoutes = ({ acc, depth, item, pathname }) => { export const MobileNav = (props) => { const { open, onClose, items } = props; const pathname = usePathname(); + const settings = useSettings(); + const showSidebarBookmarks = settings.bookmarkSidebar !== false; return ( { p: 0, }} > + {/* Bookmarks section above Dashboard */} + {showSidebarBookmarks && ( + <> + + + + )} + {/* Render all menu items */} {renderItems({ depth: 0, items, diff --git a/src/layouts/side-nav-bookmarks.js b/src/layouts/side-nav-bookmarks.js new file mode 100644 index 000000000000..527b475efebb --- /dev/null +++ b/src/layouts/side-nav-bookmarks.js @@ -0,0 +1,509 @@ +import { useCallback, useMemo, useState, useEffect, useRef } from "react"; +import NextLink from "next/link"; +import { + Box, + ButtonBase, + Collapse, + IconButton, + Stack, + SvgIcon, + Typography, +} from "@mui/material"; +import BookmarkIcon from "@mui/icons-material/Bookmark"; +import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; +import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import CloseIcon from "@mui/icons-material/Close"; +import SwapVertIcon from "@mui/icons-material/SwapVert"; +import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; +import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; +import LockIcon from "@mui/icons-material/Lock"; +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"; + +export const SideNavBookmarks = ({ collapse = false }) => { + const settings = useSettings(); + const [open, setOpen] = useState(settings.bookmarksOpen ?? false); + const reorderMode = settings.bookmarkReorderMode || "arrows"; + const locked = settings.bookmarkLocked ?? false; + const [sortOrder, setSortOrder] = useState(settings.bookmarkSortOrder || "custom"); + const [dragIndex, setDragIndex] = useState(null); + const [dragOverIndex, setDragOverIndex] = useState(null); + const [animatingPair, setAnimatingPair] = useState(null); + const [flashSort, setFlashSort] = useState(false); + const [flashLock, setFlashLock] = useState(false); + const itemRefs = useRef({}); + const touchDragRef = useRef({ startIdx: null, overIdx: null }); + + const handleToggle = useCallback(() => { + setOpen((prev) => { + const next = !prev; + settings.handleUpdate({ bookmarksOpen: next }); + return next; + }); + }, [settings]); + + const moveBookmarkUp = useCallback( + (index) => { + if (index <= 0) return; + const updatedBookmarks = [...(settings.bookmarks || [])]; + const temp = updatedBookmarks[index]; + updatedBookmarks[index] = updatedBookmarks[index - 1]; + updatedBookmarks[index - 1] = temp; + settings.handleUpdate({ bookmarks: updatedBookmarks }); + }, + [settings] + ); + + 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 }); + }, + [settings] + ); + + const removeBookmark = useCallback( + (path) => { + const updatedBookmarks = [...(settings.bookmarks || [])]; + const origIdx = updatedBookmarks.findIndex((b) => b.path === path); + if (origIdx !== -1) { + updatedBookmarks.splice(origIdx, 1); + settings.handleUpdate({ bookmarks: updatedBookmarks }); + } + }, + [settings] + ); + + const animatedMoveUp = useCallback( + (index) => { + if (index <= 0 || animatingPair) return; + const el1 = itemRefs.current[index]; + const el2 = itemRefs.current[index - 1]; + if (!el1 || !el2) { moveBookmarkUp(index); return; } + const distance = el1.getBoundingClientRect().top - el2.getBoundingClientRect().top; + setAnimatingPair({ idx1: index, idx2: index - 1, offset1: -distance, offset2: distance }); + setTimeout(() => { + moveBookmarkUp(index); + setAnimatingPair(null); + }, 250); + }, + [animatingPair, moveBookmarkUp] + ); + + 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]; + if (!el1 || !el2) { moveBookmarkDown(index); return; } + const distance = el2.getBoundingClientRect().top - el1.getBoundingClientRect().top; + setAnimatingPair({ idx1: index, idx2: index + 1, offset1: distance, offset2: -distance }); + setTimeout(() => { + moveBookmarkDown(index); + setAnimatingPair(null); + }, 250); + }, + [animatingPair, settings.bookmarks, moveBookmarkDown] + ); + + const triggerSortFlash = useCallback(() => { + setFlashSort(true); + setTimeout(() => setFlashSort(false), 600); + }, []); + + const triggerLockFlash = useCallback(() => { + setFlashLock(true); + setTimeout(() => setFlashLock(false), 600); + }, []); + + const handleToggleLock = useCallback(() => { + settings.handleUpdate({ bookmarkLocked: !locked }); + }, [settings, locked]); + + const handleDragStart = useCallback((index) => { + setDragIndex(index); + }, []); + + const handleDragOver = useCallback((e, index) => { + e.preventDefault(); + setDragOverIndex(index); + }, []); + + const handleDrop = useCallback( + (e, dropIndex) => { + e.preventDefault(); + if (dragIndex === null || dragIndex === dropIndex) { + setDragIndex(null); + setDragOverIndex(null); + return; + } + const items = [...(settings.bookmarks || [])]; + const [reordered] = items.splice(dragIndex, 1); + items.splice(dropIndex, 0, reordered); + settings.handleUpdate({ bookmarks: items }); + setDragIndex(null); + setDragOverIndex(null); + }, + [dragIndex, settings] + ); + + const handleDragEnd = useCallback(() => { + setDragIndex(null); + setDragOverIndex(null); + }, []); + + const handleSortCycle = useCallback(() => { + const next = sortOrder === "custom" ? "asc" : sortOrder === "asc" ? "desc" : "custom"; + setSortOrder(next); + settings.handleUpdate({ bookmarkSortOrder: next }); + }, [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]); + + return ( +
  • + + theme.typography.fontFamily, + fontSize: 14, + fontWeight: 500, + justifyContent: "flex-start", + px: "6px", + py: "12px", + textAlign: "left", + whiteSpace: "nowrap", + width: "100%", + }} + > + + + + + + + Bookmarks + + + {!collapse && ( + <> + + {locked ? : } + + Z" : "Z > A"} + > + {sortOrder === "custom" && } + {sortOrder === "asc" && } + {sortOrder === "desc" && } + + + )} + + + {open ? : } + + + + + + {displayBookmarks.length === 0 ? ( +
  • + + No bookmarks added yet + +
  • + ) : ( + displayBookmarks.map((bookmark, idx) => ( +
  • { itemRefs.current[idx] = el; }} + data-bookmark-index={idx} + draggable={reorderMode === "drag" && sortOrder === "custom" && !locked} + {...(reorderMode === "drag" ? { + onDragStart: (e) => { + if (locked) { e.preventDefault(); triggerLockFlash(); return; } + if (sortOrder !== "custom") { e.preventDefault(); triggerSortFlash(); return; } + handleDragStart(idx); + }, + onDragEnd: handleDragEnd, + ...(sortOrder === "custom" && !locked ? { + onDragOver: (e) => handleDragOver(e, idx), + onDrop: (e) => handleDrop(e, idx), + } : {}), + } : {})} + style={{ + ...(animatingPair && (animatingPair.idx1 === idx || animatingPair.idx2 === idx) ? { + transform: `translateY(${animatingPair.idx1 === idx ? animatingPair.offset1 : animatingPair.offset2}px)`, + transition: 'transform 250ms ease-in-out', + position: 'relative', + zIndex: animatingPair.idx1 === idx ? 1 : 0, + } : {}), + }} + > + + {reorderMode === "drag" && !locked && ( + { + if (sortOrder !== "custom") { triggerSortFlash(); return; } + touchDragRef.current.startIdx = idx; + setDragIndex(idx); + }} + onTouchMove={(e) => { + if (touchDragRef.current.startIdx === null) return; + const touch = e.touches[0]; + const draggedLi = itemRefs.current[touchDragRef.current.startIdx]; + if (draggedLi) draggedLi.style.pointerEvents = "none"; + const el = document.elementFromPoint(touch.clientX, touch.clientY); + if (draggedLi) draggedLi.style.pointerEvents = ""; + 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) { + touchDragRef.current.overIdx = overIdx; + setDragOverIndex(overIdx); + } + } + }} + onTouchEnd={() => { + const { startIdx, overIdx } = touchDragRef.current; + if (startIdx !== null && overIdx !== null && startIdx !== overIdx) { + const items = [...(settings.bookmarks || [])]; + const [reordered] = items.splice(startIdx, 1); + items.splice(overIdx, 0, reordered); + settings.handleUpdate({ bookmarks: items }); + } + touchDragRef.current = { startIdx: null, overIdx: null }; + setDragIndex(null); + setDragOverIndex(null); + }} + sx={{ + touchAction: "none", + display: "flex", + alignItems: "center", + color: "neutral.500", + cursor: sortOrder === "custom" ? "grab" : "default", + mr: 0.5, + }} + > + + + )} + theme.typography.fontFamily, + fontSize: 13, + fontWeight: 500, + justifyContent: "flex-start", + py: "6px", + textAlign: "left", + whiteSpace: "nowrap", + flexGrow: 1, + overflow: "hidden", + }} + > + + {bookmark.label} + + + + {reorderMode === "arrows" && ( + <> + { + e.preventDefault(); + if (locked) { triggerLockFlash(); return; } + sortOrder === "custom" ? animatedMoveUp(idx) : triggerSortFlash(); + }} + disabled={sortOrder === "custom" && idx === 0} + sx={{ p: "2px", opacity: sortOrder !== "custom" || locked ? 0.4 : 1 }} + > + + + { + e.preventDefault(); + if (locked) { triggerLockFlash(); return; } + sortOrder === "custom" ? animatedMoveDown(idx) : triggerSortFlash(); + }} + disabled={sortOrder === "custom" && idx === displayBookmarks.length - 1} + sx={{ p: "2px", opacity: sortOrder !== "custom" || locked ? 0.4 : 1 }} + > + + + + )} + {!(reorderMode === "drag" && locked) && ( + { + e.preventDefault(); + if (locked) { triggerLockFlash(); return; } + removeBookmark(bookmark.path); + }} + sx={{ p: "2px", ...(locked && { opacity: 0.4 }) }} + > + + + )} + + +
  • + )) + )} + + + + ); +}; diff --git a/src/layouts/side-nav-item.js b/src/layouts/side-nav-item.js index 74ad14f6969d..2a5edd287857 100644 --- a/src/layouts/side-nav-item.js +++ b/src/layouts/side-nav-item.js @@ -37,7 +37,7 @@ export const SideNavItem = (props) => { handleUpdate({ bookmarks: isBookmarked ? bookmarks.filter((bookmark) => bookmark.path !== path) - : [...bookmarks, { label: title, path }], + : bookmarks.length >= 50 ? bookmarks : [...bookmarks, { label: title, path }], }); }, [isBookmarked, bookmarks, handleUpdate, path, title] diff --git a/src/layouts/side-nav.js b/src/layouts/side-nav.js index fc2d19b396df..d2a1d7f3b7dc 100644 --- a/src/layouts/side-nav.js +++ b/src/layouts/side-nav.js @@ -1,11 +1,12 @@ -import { useState } from "react"; +import { useState, useRef, useEffect } from "react"; import { usePathname } from "next/navigation"; import PropTypes from "prop-types"; -import { Box, Drawer, Stack } from "@mui/material"; -import { Scrollbar } from "../components/scrollbar"; +import { Box, Divider, Drawer, Stack } from "@mui/material"; import { SideNavItem } from "./side-nav-item"; +import { SideNavBookmarks } from "./side-nav-bookmarks"; import { ApiGetCall } from "../api/ApiCall.jsx"; import { CippSponsor } from "../components/CippComponents/CippSponsor"; +import { useSettings } from "../hooks/use-settings"; const SIDE_NAV_WIDTH = 270; const SIDE_NAV_COLLAPSED_WIDTH = 73; // icon size + padding + border right @@ -105,6 +106,45 @@ export const SideNav = (props) => { const [hovered, setHovered] = useState(false); const collapse = !(pinned || hovered); const { data: profile } = ApiGetCall({ url: "/api/me", queryKey: "authmecipp" }); + const settings = useSettings(); + const showSidebarBookmarks = settings.bookmarkSidebar !== false; + const paperRef = useRef(null); + + // Intercept wheel events on the side nav to fully isolate scroll. + // preventDefault stops wheel events from reaching the main content, + // and manual scrollTop has no momentum so it stops instantly when the cursor leaves. + // Uses RAF-based easing to smooth out discrete mouse wheel jumps. + useEffect(() => { + const el = paperRef.current; + if (!el) return; + + let targetScrollTop = el.scrollTop; + let animating = false; + + const animate = () => { + const diff = targetScrollTop - el.scrollTop; + if (Math.abs(diff) < 0.5) { + el.scrollTop = targetScrollTop; + animating = false; + return; + } + el.scrollTop += diff * 0.25; + requestAnimationFrame(animate); + }; + + const handleWheel = (e) => { + e.preventDefault(); + const maxScroll = el.scrollHeight - el.clientHeight; + targetScrollTop = Math.max(0, Math.min(maxScroll, targetScrollTop + e.deltaY)); + if (!animating) { + animating = true; + requestAnimationFrame(animate); + } + }; + + el.addEventListener("wheel", handleWheel, { passive: false }); + return () => el.removeEventListener("wheel", handleWheel); + }, []); // Preprocess items to mark which should be open const processedItems = markOpenItems(items, pathname); @@ -115,16 +155,15 @@ export const SideNav = (props) => { open variant="permanent" PaperProps={{ - onMouseEnter: () => { - setHovered(true); - }, - onMouseLeave: () => { - setHovered(false); - }, + ref: paperRef, + onMouseEnter: () => setHovered(true), + onMouseLeave: () => setHovered(false), sx: { backgroundColor: "background.default", height: `calc(100% - ${TOP_NAV_HEIGHT}px)`, overflowX: "hidden", + overflowY: "auto", + scrollbarGutter: "stable", top: TOP_NAV_HEIGHT, transition: "width 250ms ease-in-out", width: collapse ? SIDE_NAV_COLLAPSED_WIDTH : SIDE_NAV_WIDTH, @@ -132,15 +171,6 @@ export const SideNav = (props) => { }, }} > - { p: 0, }} > + {/* Bookmarks section above Dashboard */} + {showSidebarBookmarks && ( + <> + + + + )} + {/* Render all menu items */} {renderItems({ collapse, depth: 0, @@ -170,7 +208,6 @@ export const SideNav = (props) => { {profile?.clientPrincipal && } {" "} {/* Closing tag for the parent Box */} -
    )} diff --git a/src/layouts/top-nav.js b/src/layouts/top-nav.js index ec7f06c7f3dd..e53973075533 100644 --- a/src/layouts/top-nav.js +++ b/src/layouts/top-nav.js @@ -1,14 +1,19 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import NextLink from "next/link"; import PropTypes from "prop-types"; import Bars3Icon from "@heroicons/react/24/outline/Bars3Icon"; import MoonIcon from "@heroicons/react/24/outline/MoonIcon"; import SunIcon from "@heroicons/react/24/outline/SunIcon"; import BookmarkIcon from "@mui/icons-material/Bookmark"; -import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; -import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; +import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import CloseIcon from "@mui/icons-material/Close"; +import SwapVertIcon from "@mui/icons-material/SwapVert"; +import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; +import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; +import LockIcon from "@mui/icons-material/Lock"; +import LockOpenIcon from "@mui/icons-material/LockOpen"; import { Box, Divider, @@ -31,7 +36,6 @@ import { NotificationsPopover } from "./notifications-popover"; import { useDialog } from "../hooks/use-dialog"; import { MagnifyingGlassIcon } from "@heroicons/react/24/outline"; import { CippCentralSearch } from "../components/CippComponents/CippCentralSearch"; -import { applySort } from "../utils/apply-sort"; const TOP_NAV_HEIGHT = 64; @@ -40,6 +44,9 @@ export const TopNav = (props) => { const { onNavOpen } = props; const settings = useSettings(); const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md")); + const showPopoverBookmarks = settings.bookmarkPopover === true; + const reorderMode = settings.bookmarkReorderMode || "arrows"; + const locked = settings.bookmarkLocked ?? false; const handleThemeSwitch = useCallback(() => { const themeName = settings.currentTheme?.value === "light" ? "dark" : "light"; settings.handleUpdate({ @@ -49,7 +56,14 @@ export const TopNav = (props) => { }, [settings]); const [anchorEl, setAnchorEl] = useState(null); - const [sortOrder, setSortOrder] = useState("asc"); + const [sortOrder, setSortOrder] = useState(settings.bookmarkSortOrder || "custom"); + const [dragIndex, setDragIndex] = useState(null); + const [dragOverIndex, setDragOverIndex] = useState(null); + const [animatingPair, setAnimatingPair] = useState(null); + const [flashSort, setFlashSort] = useState(false); + const [flashLock, setFlashLock] = useState(false); + const itemRefs = useRef({}); + const touchDragRef = useRef({ startIdx: null, overIdx: null }); const handleBookmarkClick = (event) => { setAnchorEl(event.currentTarget); @@ -59,43 +73,121 @@ export const TopNav = (props) => { setAnchorEl(null); }; - const handleSortToggle = () => { - const newSortOrder = sortOrder === "asc" ? "desc" : "asc"; - setSortOrder(newSortOrder); + const handleDragStart = (index) => { + setDragIndex(index); + }; - // Save the new sort order and re-order bookmarks - const sortedBookmarks = applySort(settings.bookmarks || [], "label", newSortOrder); - settings.handleUpdate({ - bookmarks: sortedBookmarks, - sortOrder: newSortOrder, - }); + const handleDragOver = (e, index) => { + e.preventDefault(); + setDragOverIndex(index); + }; + + const handleDrop = (e, dropIndex) => { + e.preventDefault(); + if (dragIndex === null || dragIndex === dropIndex) { + setDragIndex(null); + setDragOverIndex(null); + return; + } + const items = [...(settings.bookmarks || [])]; + const [reordered] = items.splice(dragIndex, 1); + items.splice(dropIndex, 0, reordered); + settings.handleUpdate({ bookmarks: items }); + setDragIndex(null); + setDragOverIndex(null); + }; + + const handleDragEnd = () => { + setDragIndex(null); + setDragOverIndex(null); }; - // Move a bookmark up in the list const moveBookmarkUp = (index) => { if (index <= 0) return; - const updatedBookmarks = [...(settings.bookmarks || [])]; const temp = updatedBookmarks[index]; updatedBookmarks[index] = updatedBookmarks[index - 1]; updatedBookmarks[index - 1] = temp; - settings.handleUpdate({ bookmarks: updatedBookmarks }); }; - // Move a bookmark down in the list 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 }); }; + const removeBookmark = (path) => { + const updatedBookmarks = [...(settings.bookmarks || [])]; + const origIdx = updatedBookmarks.findIndex((b) => b.path === path); + if (origIdx !== -1) { + updatedBookmarks.splice(origIdx, 1); + settings.handleUpdate({ bookmarks: updatedBookmarks }); + } + }; + + const triggerSortFlash = () => { + setFlashSort(true); + setTimeout(() => setFlashSort(false), 600); + }; + + const triggerLockFlash = () => { + setFlashLock(true); + setTimeout(() => setFlashLock(false), 600); + }; + + const handleToggleLock = () => { + settings.handleUpdate({ bookmarkLocked: !locked }); + }; + + const animatedMoveUp = (index) => { + if (index <= 0 || animatingPair) return; + const el1 = itemRefs.current[index]; + const el2 = itemRefs.current[index - 1]; + if (!el1 || !el2) { moveBookmarkUp(index); return; } + const distance = el1.getBoundingClientRect().top - el2.getBoundingClientRect().top; + setAnimatingPair({ idx1: index, idx2: index - 1, offset1: -distance, offset2: distance }); + setTimeout(() => { + moveBookmarkUp(index); + setAnimatingPair(null); + }, 250); + }; + + 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]; + if (!el1 || !el2) { moveBookmarkDown(index); return; } + const distance = el2.getBoundingClientRect().top - el1.getBoundingClientRect().top; + setAnimatingPair({ idx1: index, idx2: index + 1, offset1: distance, offset2: -distance }); + setTimeout(() => { + moveBookmarkDown(index); + setAnimatingPair(null); + }, 250); + }; + + const handleSortCycle = () => { + const next = sortOrder === "custom" ? "asc" : sortOrder === "asc" ? "desc" : "custom"; + setSortOrder(next); + settings.handleUpdate({ bookmarkSortOrder: next }); + }; + + 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]); + const popoverOpen = Boolean(anchorEl); + const popoverId = popoverOpen ? "bookmark-popover" : undefined; + useEffect(() => { const handleKeyDown = (event) => { if ((event.metaKey || event.ctrlKey) && event.key === "k") { @@ -109,22 +201,10 @@ export const TopNav = (props) => { }; }, []); - useEffect(() => { - if (settings.sortOrder) { - setSortOrder(settings.sortOrder); - } - }, [settings.sortOrder]); - const openSearch = () => { searchDialog.handleOpen(); }; - // Use the sorted bookmarks if sorting is applied, otherwise use the bookmarks in their current order - const displayBookmarks = settings.bookmarks || []; - - const open = Boolean(anchorEl); - const id = open ? "bookmark-popover" : undefined; - return ( {
    - - - - - - - - - - - {sortOrder === "asc" ? : } - - - Sort Alphabetically - - {displayBookmarks.length === 0 ? ( - - No bookmarks added yet} - /> - - ) : ( - displayBookmarks.map((bookmark, idx) => ( - - handleBookmarkClose()} + {showPopoverBookmarks && ( + <> + + + + + + + + + - {bookmark.label} - - - { - e.preventDefault(); - moveBookmarkUp(idx); - }} - disabled={idx === 0} - > - - - { - e.preventDefault(); - moveBookmarkDown(idx); + {locked ? : } + + Z" : "Z > A"} + > + {sortOrder === "custom" && } + {sortOrder === "asc" && } + {sortOrder === "desc" && } + + + {sortOrder === "custom" ? "Custom order" : sortOrder === "asc" ? "A > Z" : "Z > A"} + + + + {displayBookmarks.length === 0 ? ( + + No bookmarks added yet} + /> + + ) : ( + displayBookmarks.map((bookmark, idx) => ( + { itemRefs.current[idx] = el; }} + data-bookmark-index={idx} + draggable={reorderMode === "drag" && sortOrder === "custom" && !locked} + {...(reorderMode === "drag" ? { + onDragStart: (e) => { + if (locked) { e.preventDefault(); triggerLockFlash(); return; } + if (sortOrder !== "custom") { e.preventDefault(); triggerSortFlash(); return; } + handleDragStart(idx); + }, + onDragEnd: handleDragEnd, + ...(sortOrder === "custom" && !locked ? { + onDragOver: (e) => handleDragOver(e, idx), + onDrop: (e) => handleDrop(e, idx), + } : {}), + } : {})} + sx={{ + color: "inherit", + display: "flex", + justifyContent: "space-between", + "&:hover .bookmark-controls": { + opacity: 1, + }, + ...(sortOrder === "custom" && reorderMode === "drag" && dragIndex === idx && { + opacity: 0.4, + }), + ...(sortOrder === "custom" && reorderMode === "drag" && dragOverIndex === idx && dragIndex !== idx && { + borderTop: "2px solid", + borderColor: "primary.main", + }), + ...(animatingPair && (animatingPair.idx1 === idx || animatingPair.idx2 === idx) && { + transform: `translateY(${animatingPair.idx1 === idx ? animatingPair.offset1 : animatingPair.offset2}px)`, + transition: 'transform 250ms ease-in-out', + position: 'relative', + zIndex: animatingPair.idx1 === idx ? 1 : 0, + }), }} - disabled={idx === displayBookmarks.length - 1} > - - - - - )) - )} - - + {reorderMode === "drag" && !locked && ( + { + if (sortOrder !== "custom") { triggerSortFlash(); return; } + touchDragRef.current.startIdx = idx; + setDragIndex(idx); + }} + onTouchMove={(e) => { + if (touchDragRef.current.startIdx === null) return; + const touch = e.touches[0]; + const draggedLi = itemRefs.current[touchDragRef.current.startIdx]; + if (draggedLi) draggedLi.style.pointerEvents = "none"; + const el = document.elementFromPoint(touch.clientX, touch.clientY); + if (draggedLi) draggedLi.style.pointerEvents = ""; + 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) { + touchDragRef.current.overIdx = overIdx; + setDragOverIndex(overIdx); + } + } + }} + onTouchEnd={() => { + const { startIdx, overIdx } = touchDragRef.current; + if (startIdx !== null && overIdx !== null && startIdx !== overIdx) { + const items = [...(settings.bookmarks || [])]; + const [reordered] = items.splice(startIdx, 1); + items.splice(overIdx, 0, reordered); + settings.handleUpdate({ bookmarks: items }); + } + touchDragRef.current = { startIdx: null, overIdx: null }; + setDragIndex(null); + setDragOverIndex(null); + }} + sx={{ + touchAction: "none", + display: "flex", + alignItems: "center", + color: "neutral.500", + cursor: sortOrder === "custom" ? "grab" : "default", + mr: 1, + }} + > + + + )} + handleBookmarkClose()} + sx={{ + textDecoration: "none", + color: "inherit", + flexGrow: 1, + marginRight: 2, + }} + > + {bookmark.label} + + + {reorderMode === "arrows" && ( + <> + { + e.preventDefault(); + if (locked) { triggerLockFlash(); return; } + sortOrder === "custom" ? animatedMoveUp(idx) : triggerSortFlash(); + }} + disabled={sortOrder === "custom" && idx === 0} + sx={{ opacity: sortOrder !== "custom" || locked ? 0.4 : 1 }} + > + + + { + e.preventDefault(); + if (locked) { triggerLockFlash(); return; } + sortOrder === "custom" ? animatedMoveDown(idx) : triggerSortFlash(); + }} + disabled={sortOrder === "custom" && idx === displayBookmarks.length - 1} + sx={{ opacity: sortOrder !== "custom" || locked ? 0.4 : 1 }} + > + + + + )} + {!(reorderMode === "drag" && locked) && ( + { + e.preventDefault(); + if (locked) { triggerLockFlash(); return; } + removeBookmark(bookmark.path); + }} + sx={{ ...(locked && { opacity: 0.4 }) }} + > + + + )} + + + )) + )} + + + + )} { 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" }); + + 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 (Object.keys(updates).length > 0) { + settings.handleUpdate(updates); + } + }, [watchedBookmarkSidebar, watchedBookmarkPopover, watchedBookmarkReorderMode]); + // Update form when initial user type is determined useEffect(() => { if (initialUserType !== null && auth.data?.clientPrincipal?.userDetails) { @@ -311,6 +332,48 @@ const Page = () => { }, ]} /> + + ), + }, + { + label: "Show Popover Bookmarks", + value: ( + + ), + }, + { + label: "Bookmark Reorder Mode", + value: ( + + ), + }, + ]} + />
    diff --git a/src/pages/cipp/settings/backup.js b/src/pages/cipp/settings/backup.js index 6de4144029b6..dc1786a10b5e 100644 --- a/src/pages/cipp/settings/backup.js +++ b/src/pages/cipp/settings/backup.js @@ -1,12 +1,11 @@ import { + Alert, Box, Button, CardContent, Stack, Typography, Skeleton, - Alert, - AlertTitle, Input, FormControl, FormLabel, @@ -25,24 +24,25 @@ import { NextPlan, SettingsBackupRestore, Storage, - Warning, - CheckCircle, - Error as ErrorIcon, - UploadFile, } from "@mui/icons-material"; import ReactTimeAgo from "react-time-ago"; import { CippDataTable } from "../../../components/CippTable/CippDataTable"; -import { CippApiResults } from "../../../components/CippComponents/CippApiResults"; import { CippApiDialog } from "../../../components/CippComponents/CippApiDialog"; -import { BackupValidator, BackupValidationError } from "../../../utils/backupValidation"; +import { CippRestoreWizard } from "../../../components/CippComponents/CippRestoreWizard"; +import { BackupValidator } from "../../../utils/backupValidation"; import { useState } from "react"; import { useDialog } from "../../../hooks/use-dialog"; const Page = () => { const [validationResult, setValidationResult] = useState(null); - const restoreDialog = useDialog(); + const wizardDialog = useDialog(); + const runBackupDialog = useDialog(); + const enableBackupDialog = useDialog(); + const disableBackupDialog = useDialog(); const [selectedBackupFile, setSelectedBackupFile] = useState(null); const [selectedBackupData, setSelectedBackupData] = useState(null); + const [selectedBackupName, setSelectedBackupName] = useState(null); + const [wizardLoading, setWizardLoading] = useState(false); const backupList = ApiGetCall({ url: "/api/ExecListBackup", @@ -60,98 +60,14 @@ const Page = () => { queryKey: "ScheduledBackup", }); - const backupAction = ApiPostCall({ - urlFromData: true, - }); - const downloadAction = ApiPostCall({ urlFromData: true, }); - const runBackup = ApiPostCall({ + const fetchForRestore = ApiPostCall({ urlFromData: true, - relatedQueryKeys: ["BackupList", "ScheduledBackup"], }); - const enableBackupSchedule = ApiPostCall({ - urlFromData: true, - relatedQueryKeys: ["ScheduledBackup"], - }); - - // Component for displaying validation results - const ValidationResultsDisplay = ({ result }) => { - if (!result) return null; - - return ( - - {result.isValid ? ( - }> - Backup Validation Successful - - The backup file is valid and ready for restoration. - - {result.validRows !== undefined && result.totalRows !== undefined && ( - - Import Summary: {result.validRows} valid rows out of{" "} - {result.totalRows} total rows will be imported. - - )} - {result.repaired && ( - - Note: The backup file had minor issues that were automatically - repaired. - - )} - {result.warnings.length > 0 && ( - - - Warnings: - -
      - {result.warnings.map((warning, index) => ( -
    • - - {warning} - -
    • - ))} -
    -
    - )} -
    - ) : ( - }> - Backup Validation Failed - - The backup file is corrupted and cannot be restored safely. - - {result.validRows !== undefined && result.totalRows !== undefined && ( - - Analysis: Found {result.validRows} valid rows out of{" "} - {result.totalRows} total rows. - - )} - - - Errors found: - -
      - {result.errors.map((error, index) => ( -
    • - {error} -
    • - ))} -
    -
    - - Please try downloading a fresh backup or contact support if this issue persists. - -
    - )} -
    - ); - }; - const NextBackupRun = (props) => { const date = new Date(props.date); if (isNaN(date)) { @@ -161,20 +77,12 @@ const Page = () => { } }; - const handleCreateBackup = () => { - runBackup.mutate({ - url: "/api/ExecRunBackup", - data: {}, - }); - }; - - const handleEnableScheduledBackup = () => { - enableBackupSchedule.mutate({ - url: "/api/ExecSetCIPPAutoBackup", - data: { - Enabled: true, - }, - }); + const openWizardWithData = ({ file, validation, data, backupName = null }) => { + setValidationResult(validation); + setSelectedBackupFile(file); + setSelectedBackupData(validation.isValid && data ? data : null); + setSelectedBackupName(backupName); + wizardDialog.handleOpen(); }; const handleRestoreBackupUpload = (e) => { @@ -182,53 +90,68 @@ const Page = () => { if (!file) return; const reader = new FileReader(); - reader.onload = (e) => { + reader.onload = (evt) => { try { - const rawContent = e.target.result; - - // Validate the backup file + const rawContent = evt.target.result; const validation = BackupValidator.validateBackup(rawContent); - setValidationResult(validation); - - // Store the file info and validated data - setSelectedBackupFile({ - name: file.name, - size: file.size, - lastModified: new Date(file.lastModified), + openWizardWithData({ + file: { name: file.name, size: file.size, lastModified: new Date(file.lastModified) }, + validation, + data: validation.data, }); - - if (validation.isValid) { - setSelectedBackupData(validation.data); - } else { - setSelectedBackupData(null); - } - - // Open the confirmation dialog - restoreDialog.handleOpen(); - - // Clear the file input - e.target.value = null; } catch (error) { console.error("Backup validation error:", error); - setValidationResult({ - isValid: false, - errors: [`Validation failed: ${error.message}`], - warnings: [], - repaired: false, - }); - setSelectedBackupFile({ - name: file.name, - size: file.size, - lastModified: new Date(file.lastModified), + openWizardWithData({ + file: { name: file.name, size: file.size, lastModified: new Date(file.lastModified) }, + validation: { + isValid: false, + errors: [`Validation failed: ${error.message}`], + warnings: [], + repaired: false, + }, + data: null, }); - setSelectedBackupData(null); - restoreDialog.handleOpen(); - e.target.value = null; } + // Clear file input + e.target.value = null; }; reader.readAsText(file); }; + const handleTableRestoreAction = (row) => { + // Open immediately with loading state + setValidationResult(null); + setSelectedBackupFile({ + name: row.BackupName, + size: null, + lastModified: row.Timestamp ? new Date(row.Timestamp) : null, + }); + setSelectedBackupData(null); + setSelectedBackupName(row.BackupName); + setWizardLoading(true); + wizardDialog.handleOpen(); + fetchForRestore.mutate( + { + url: `/api/ExecListBackup?BackupName=${row.BackupName}`, + data: {}, + }, + { + onSuccess: (data) => { + const jsonString = data?.data?.[0]?.Backup; + if (!jsonString) { + setWizardLoading(false); + return; + } + const validation = BackupValidator.validateBackup(jsonString); + setValidationResult(validation); + setSelectedBackupData(validation.isValid && validation.data ? validation.data : null); + setWizardLoading(false); + }, + onError: () => setWizardLoading(false), + }, + ); + }; + const handleDownloadBackupAction = (row) => { downloadAction.mutate( { @@ -245,30 +168,10 @@ const Page = () => { // Validate the backup before downloading const validation = BackupValidator.validateBackup(jsonString); - let finalJsonString = jsonString; + let downloadContent = jsonString; if (validation.repaired) { // Use the repaired version if available - finalJsonString = JSON.stringify(validation.data, null, 2); - } - - // Create a validation report comment at the top - let downloadContent = finalJsonString; - if (!validation.isValid || validation.warnings.length > 0) { - const report = { - validationReport: { - timestamp: new Date().toISOString(), - isValid: validation.isValid, - repaired: validation.repaired, - errors: validation.errors, - warnings: validation.warnings, - }, - }; - - downloadContent = `// CIPP Backup Validation Report\n// ${JSON.stringify( - report, - null, - 2 - )}\n\n${finalJsonString}`; + downloadContent = JSON.stringify(validation.data, null, 2); } const blob = new Blob([downloadContent], { type: "application/json" }); @@ -281,7 +184,7 @@ const Page = () => { document.body.removeChild(a); URL.revokeObjectURL(url); }, - } + }, ); }; @@ -289,12 +192,8 @@ const Page = () => { { label: "Restore Backup", icon: , - type: "POST", - url: "/api/ExecRestoreBackup", - data: { BackupName: "BackupName" }, - confirmText: "Are you sure you want to restore this backup?", - relatedQueryKeys: ["BackupList"], - multiPost: false, + noConfirm: true, + customFunction: handleTableRestoreAction, hideBulk: true, }, { @@ -311,52 +210,50 @@ const Page = () => { title="CIPP Backup" backButtonTitle="Settings" infoBar={ - , - name: "Backup Count", - data: backupList.data?.length, - }, - { - icon: , - name: "Last Backup", - data: backupList.data?.[0]?.Timestamp ? ( - - ) : ( - "No Backups" - ), - }, - { - icon: , - name: "Automatic Backups", - data: - scheduledBackup.data?.[0]?.Name === "Automated CIPP Backup" - ? "Enabled" - : "Disabled", - }, - { - icon: , - name: "Next Backup", - data: , - }, - ]} - /> + + + Backups are stored in the storage account associated with your CIPP instance. You can + download or restore specific points in time from the list below. Enable automatic + backups to have CIPP create daily backups using the scheduler. + + , + name: "Backup Count", + data: backupList.data?.length, + }, + { + icon: , + name: "Last Backup", + data: backupList.data?.[0]?.Timestamp ? ( + + ) : ( + "No Backups" + ), + }, + { + icon: , + name: "Automatic Backups", + data: + scheduledBackup.data?.[0]?.Name === "Automated CIPP Backup" + ? "Enabled" + : "Disabled", + }, + { + icon: , + name: "Next Backup", + data: , + }, + ]} + /> + } > - - - Backups are stored in the storage account associated with your CIPP instance. You can - download or restore specific points in time from the list below. Enable automatic - backups to have CIPP create daily backups using the scheduler. - + {backupList.isSuccess ? ( - - - - - + { <> - {scheduledBackup.isSuccess && scheduledBackup.data?.[0]?.Name !== "Automated CIPP Backup" && ( - <> - - + + )} + {scheduledBackup.isSuccess && + scheduledBackup.data?.[0]?.Name === "Automated CIPP Backup" && ( + )} @@ -419,140 +317,58 @@ const Page = () => { - {/* Backup Restore Confirmation Dialog */} { - restoreDialog.handleClose(); - // Clear state when user manually closes the dialog - setValidationResult(null); - setSelectedBackupFile(null); - setSelectedBackupData(null); - }, - }} + createDialog={runBackupDialog} + title="Run Backup" api={{ + url: "/api/ExecRunBackup", type: "POST", - url: "/api/ExecRestoreBackup", - customDataformatter: () => selectedBackupData, - confirmText: validationResult?.isValid - ? "Are you sure you want to restore this backup? This will overwrite your current CIPP configuration." - : null, - onSuccess: () => { - // Don't auto-close the dialog - let user see the results and close manually - // The dialog will show the API results and user can close when ready - }, + data: {}, + confirmText: "Are you sure you want to run a backup now?", + relatedQueryKeys: ["BackupList", "ScheduledBackup"], }} - relatedQueryKeys={["BackupList", "ScheduledBackup"]} - > - {({ formHook, row }) => ( - - {/* File Information */} - {selectedBackupFile && ( - - - - Selected File - - (theme.palette.mode === "dark" ? "grey.800" : "grey.50"), - borderRadius: 1, - border: (theme) => `1px solid ${theme.palette.divider}`, - }} - > - - - Filename: {selectedBackupFile.name} - - - Size: {(selectedBackupFile.size / 1024 / 1024).toFixed(2)} MB - - - Last Modified:{" "} - {selectedBackupFile.lastModified.toLocaleString()} - - - - - )} - - {/* Validation Results */} - + /> - {/* Additional Information if Validation Failed */} - {validationResult && !validationResult.isValid && ( - }> - Restore Blocked - The backup file cannot be restored due to validation errors. Please ensure you have - a valid backup file before proceeding. - - )} + - {/* Success Information with Data Summary */} - {validationResult?.isValid && selectedBackupData && ( - - - - Backup Contents - - - theme.palette.mode === "dark" ? "success.dark" : "success.light", - borderRadius: 1, - border: (theme) => `1px solid ${theme.palette.success.main}`, - color: (theme) => - theme.palette.mode === "dark" ? "success.contrastText" : "success.dark", - }} - > - - - Total Objects:{" "} - {Array.isArray(selectedBackupData) ? selectedBackupData.length : "Unknown"} - - {validationResult.repaired && ( - - Status: Automatically repaired and validated - - )} - {validationResult.warnings.length > 0 && ( - - Warnings: {validationResult.warnings.length} warning(s) - noted - - )} - - - - )} - - )} - + + + { + wizardDialog.handleClose(); + setValidationResult(null); + setSelectedBackupFile(null); + setSelectedBackupData(null); + setSelectedBackupName(null); + setWizardLoading(false); + }} + validationResult={validationResult} + backupFile={selectedBackupFile} + backupData={selectedBackupData} + backupName={selectedBackupName} + isLoading={wizardLoading} + /> ); }; diff --git a/src/pages/domains.js b/src/pages/domains.js deleted file mode 100644 index c70d96f5364b..000000000000 --- a/src/pages/domains.js +++ /dev/null @@ -1,149 +0,0 @@ -import Head from "next/head"; -import { useRef } from "react"; -import { - Alert, - Box, - Button, - Card, - CircularProgress, - Container, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - Divider, - Stack, - TextField, - Typography, -} from "@mui/material"; -import { useDialog } from "../hooks/use-dialog"; -import { Layout as DashboardLayout } from "../layouts/index.js"; -import { CippDataTable } from "../components/CippTable/CippDataTable"; -import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; -import { TrashIcon } from "@heroicons/react/24/outline"; -import { ApiPostCall } from "../api/ApiCall"; -import { useFormik } from "formik"; - -const Page = () => { - const ref = useRef(); - const createDialog = useDialog(); - const domainPostRequest = ApiPostCall({ - urlFromData: true, - relatedQueryKeys: "users", - }); - - const formik = useFormik({ - initialValues: { - domainName: "", - }, - onSubmit: async (values, helpers) => { - try { - domainPostRequest.mutate({ url: "/api/AddCustomDomain", ...values }); - helpers.resetForm(); - helpers.setStatus({ success: true }); - helpers.setSubmitting(false); - } catch (err) { - helpers.setStatus({ success: false }); - helpers.setErrors({ submit: err.message }); - helpers.setSubmitting(false); - } - }, - }); - - return ( - <> - - Devices - - - - - - - - - Add Domain} - actions={[ - { - label: "Delete domain", - type: "GET", - url: "api/DeleteCustomDomain", - data: { domain: "Domain" }, - icon: , - }, - ]} - simple={false} - api={{ url: "api/ListCustomDomains" }} - columns={[ - { - header: "Domain", - accessorKey: "Domain", - }, - { - header: "Status", - accessorKey: "Status", - }, - ]} - /> - - - - - -
    - Add Domain - - - To add a domain to your instance, set your preferred CNAME to your CIPP default - domain, then add the domain here. - - - - - - - {domainPostRequest.isPending && ( - - Adding domain... - - )} - {domainPostRequest.isError && ( - - Error adding domain: {domainPostRequest.error.response.data} - - )} - - - - - -
    -
    - - ); -}; - -Page.getLayout = (page) => {page}; - -export default Page; diff --git a/src/pages/email/administration/mailboxes/index.js b/src/pages/email/administration/mailboxes/index.js index 19913b56cf8b..3a7af2fa1223 100644 --- a/src/pages/email/administration/mailboxes/index.js +++ b/src/pages/email/administration/mailboxes/index.js @@ -125,10 +125,11 @@ const Page = () => { relatedQueryKeys: [`ListMailboxes-${currentTenant}`], data: { Name: "Mailboxes", + Types: "None", }, onSuccess: (response) => { - if (response?.QueueId) { - setSyncQueueId(response.QueueId); + if (response?.Metadata?.QueueId) { + setSyncQueueId(response.Metadata.QueueId); } }, }} diff --git a/src/pages/endpoint/MEM/reusable-settings-templates/edit.jsx b/src/pages/endpoint/MEM/reusable-settings-templates/edit.jsx index 046a14a70d20..82baa233ba27 100644 --- a/src/pages/endpoint/MEM/reusable-settings-templates/edit.jsx +++ b/src/pages/endpoint/MEM/reusable-settings-templates/edit.jsx @@ -117,7 +117,7 @@ const EditReusableSettingsTemplate = () => { return { ...templateData, // Normalize all known casing variants to the canonical RawJSON property - RawJSON: templateData.RawJSON ?? templateData.RAWJson ?? templateData.RAWJSON, + RawJSON: templateData.RawJSON ?? templateData.RAWJson ?? templateData.rawJSON, }; }, [templateData]); diff --git a/src/pages/endpoint/MEM/reusable-settings/edit.jsx b/src/pages/endpoint/MEM/reusable-settings/edit.jsx index 3fe089ac520d..ac1285e02f42 100644 --- a/src/pages/endpoint/MEM/reusable-settings/edit.jsx +++ b/src/pages/endpoint/MEM/reusable-settings/edit.jsx @@ -36,22 +36,26 @@ const EditReusableSetting = () => { const record = Array.isArray(settingQuery.data) ? settingQuery.data[0] : settingQuery.data; + const getRawJson = (source) => source?.RawJSON ?? ""; + useEffect(() => { if (record) { + const rawJsonValue = getRawJson(record); reset({ tenantFilter: effectiveTenant, ID: record.id, displayName: record.displayName, description: record.description, - rawJSON: record.RawJSON, + rawJSON: rawJsonValue, }); } }, [record, effectiveTenant, reset]); const safeJson = () => { - if (!record?.RawJSON) return null; + const rawJsonValue = getRawJson(record); + if (!rawJsonValue) return null; try { - return JSON.parse(record.RawJSON); + return JSON.parse(rawJsonValue); } catch (e) { console.error("Failed to parse RawJSON for reusable setting preview", { error: e, diff --git a/src/pages/endpoint/applications/list/index.js b/src/pages/endpoint/applications/list/index.js index 0405962e188b..49e5bc4b2816 100644 --- a/src/pages/endpoint/applications/list/index.js +++ b/src/pages/endpoint/applications/list/index.js @@ -20,6 +20,11 @@ const assignmentModeOptions = [ { label: "Append to existing assignments", value: "append" }, ]; +const assignmentFilterTypeOptions = [ + { label: "Include - Apply to devices matching filter", value: "include" }, + { label: "Exclude - Apply to devices NOT matching filter", value: "exclude" }, +]; + const getAppAssignmentSettingsType = (odataType) => { if (!odataType || typeof odataType !== "string") { return undefined; @@ -33,15 +38,35 @@ const Page = () => { const syncDialog = useDialog(); const tenant = useSettings().currentTenant; + const getAssignmentFilterFields = () => [ + { + type: "autoComplete", + name: "assignmentFilter", + label: "Assignment Filter (Optional)", + multiple: false, + creatable: false, + api: { + url: "/api/ListAssignmentFilters", + queryKey: `ListAssignmentFilters-${tenant}`, + labelField: (filter) => filter.displayName, + valueField: "displayName", + }, + }, + { + type: "radio", + name: "assignmentFilterType", + label: "Assignment Filter Mode", + options: assignmentFilterTypeOptions, + defaultValue: "include", + helperText: "Choose whether to include or exclude devices matching the filter.", + }, + ]; + const actions = [ { label: "Assign to All Users", type: "POST", url: "/api/ExecAssignApp", - data: { - AssignTo: "!AllUsers", - ID: "id", - }, fields: [ { type: "radio", @@ -62,7 +87,22 @@ const Page = () => { helperText: "Replace will overwrite existing assignments. Append keeps current assignments and adds/overwrites only for the selected groups/intents.", }, + ...getAssignmentFilterFields(), ], + customDataformatter: (row, action, formData) => { + const tenantFilterValue = tenant === "AllTenants" && row?.Tenant ? row.Tenant : tenant; + return { + tenantFilter: tenantFilterValue, + ID: row?.id, + AssignTo: "AllUsers", + Intent: formData?.Intent || "Required", + assignmentMode: formData?.assignmentMode || "replace", + AssignmentFilterName: formData?.assignmentFilter?.value || null, + AssignmentFilterType: formData?.assignmentFilter?.value + ? formData?.assignmentFilterType || "include" + : null, + }; + }, confirmText: 'Are you sure you want to assign "[displayName]" to all users?', icon: , color: "info", @@ -71,10 +111,6 @@ const Page = () => { label: "Assign to All Devices", type: "POST", url: "/api/ExecAssignApp", - data: { - AssignTo: "!AllDevices", - ID: "id", - }, fields: [ { type: "radio", @@ -95,7 +131,22 @@ const Page = () => { helperText: "Replace will overwrite existing assignments. Append keeps current assignments and adds/overwrites only for the selected groups/intents.", }, + ...getAssignmentFilterFields(), ], + customDataformatter: (row, action, formData) => { + const tenantFilterValue = tenant === "AllTenants" && row?.Tenant ? row.Tenant : tenant; + return { + tenantFilter: tenantFilterValue, + ID: row?.id, + AssignTo: "AllDevices", + Intent: formData?.Intent || "Required", + assignmentMode: formData?.assignmentMode || "replace", + AssignmentFilterName: formData?.assignmentFilter?.value || null, + AssignmentFilterType: formData?.assignmentFilter?.value + ? formData?.assignmentFilterType || "include" + : null, + }; + }, confirmText: 'Are you sure you want to assign "[displayName]" to all devices?', icon: , color: "info", @@ -104,10 +155,6 @@ const Page = () => { label: "Assign Globally (All Users / All Devices)", type: "POST", url: "/api/ExecAssignApp", - data: { - AssignTo: "!AllDevicesAndUsers", - ID: "id", - }, fields: [ { type: "radio", @@ -128,7 +175,22 @@ const Page = () => { helperText: "Replace will overwrite existing assignments. Append keeps current assignments and adds/overwrites only for the selected groups/intents.", }, + ...getAssignmentFilterFields(), ], + customDataformatter: (row, action, formData) => { + const tenantFilterValue = tenant === "AllTenants" && row?.Tenant ? row.Tenant : tenant; + return { + tenantFilter: tenantFilterValue, + ID: row?.id, + AssignTo: "AllDevicesAndUsers", + Intent: formData?.Intent || "Required", + assignmentMode: formData?.assignmentMode || "replace", + AssignmentFilterName: formData?.assignmentFilter?.value || null, + AssignmentFilterType: formData?.assignmentFilter?.value + ? formData?.assignmentFilterType || "include" + : null, + }; + }, confirmText: 'Are you sure you want to assign "[displayName]" to all users and devices?', icon: , color: "info", @@ -188,6 +250,7 @@ const Page = () => { helperText: "Replace will overwrite existing assignments. Append keeps current assignments and adds/overwrites only for the selected groups/intents.", }, + ...getAssignmentFilterFields(), ], customDataformatter: (row, action, formData) => { const selectedGroups = Array.isArray(formData?.groupTargets) ? formData.groupTargets : []; @@ -200,6 +263,10 @@ const Page = () => { Intent: formData?.assignmentIntent || "Required", AssignmentMode: formData?.assignmentMode || "replace", AppType: getAppAssignmentSettingsType(row?.["@odata.type"]), + AssignmentFilterName: formData?.assignmentFilter?.value || null, + AssignmentFilterType: formData?.assignmentFilter?.value + ? formData?.assignmentFilterType || "include" + : null, }; }, }, diff --git a/src/pages/identity/administration/groups/index.js b/src/pages/identity/administration/groups/index.js index 1a05309ee7d2..3320ac353204 100644 --- a/src/pages/identity/administration/groups/index.js +++ b/src/pages/identity/administration/groups/index.js @@ -5,13 +5,13 @@ import Link from "next/link"; import { TrashIcon, EyeIcon } from "@heroicons/react/24/outline"; import { Visibility, - VisibilityOff, GroupAdd, Edit, LockOpen, Lock, GroupSharp, CloudSync, + RocketLaunch, } from "@mui/icons-material"; import { Stack } from "@mui/system"; import { useState } from "react"; @@ -313,6 +313,13 @@ const Page = () => { + } apiUrl="/api/ListGroups" diff --git a/src/pages/identity/administration/users/user/conditional-access.jsx b/src/pages/identity/administration/users/user/conditional-access.jsx index ea58b93207dc..8449148b8562 100644 --- a/src/pages/identity/administration/users/user/conditional-access.jsx +++ b/src/pages/identity/administration/users/user/conditional-access.jsx @@ -133,6 +133,7 @@ const Page = () => { label="Select the application to test" name="includeApplications" multiple={false} + creatable={false} api={{ tenantFilter: tenant, url: "/api/ListGraphRequest", @@ -149,38 +150,19 @@ const Page = () => { $top: 999, }, }} + validators={{ required: "Application is required" }} formControl={formControl} /> {/* Optional Parameters */} Optional Parameters: - - {/* Test from this country */} - ({ - value: Code, - label: Name, - }))} - formControl={formControl} - /> - - {/* Test from this IP */} - - {/* Device Platform */} { type="autoComplete" label="Select the client application type to test" name="clientAppType" + multiple={false} + creatable={false} options={[ { value: "all", label: "All" }, { value: "Browser", label: "Browser" }, @@ -210,11 +194,51 @@ const Page = () => { formControl={formControl} /> + {/* Authentication Flow */} + + + {/* Test from this IP */} + + + {/* Test from this country */} + ({ + value: Code, + label: Name, + }))} + formControl={formControl} + /> + {/* Sign-in risk level */} { type="autoComplete" label="Select the user risk level of the user signing in" name="userRiskLevel" + multiple={false} + creatable={false} options={[ { value: "low", label: "Low" }, { value: "medium", label: "Medium" }, @@ -246,9 +272,9 @@ const Page = () => { diff --git a/src/pages/identity/administration/vacation-mode/add/index.js b/src/pages/identity/administration/vacation-mode/add/index.js new file mode 100644 index 000000000000..eb4f73a75cbc --- /dev/null +++ b/src/pages/identity/administration/vacation-mode/add/index.js @@ -0,0 +1,99 @@ +import { Layout as DashboardLayout } from "../../../../../layouts/index.js"; +import CippWizardPage from "../../../../../components/CippWizard/CippWizardPage.jsx"; +import { CippTenantStep } from "../../../../../components/CippWizard/CippTenantStep.jsx"; +import { CippWizardAutoComplete } from "../../../../../components/CippWizard/CippWizardAutoComplete"; +import { CippWizardVacationActions } from "../../../../../components/CippWizard/CippWizardVacationActions"; +import { CippWizardVacationSchedule } from "../../../../../components/CippWizard/CippWizardVacationSchedule"; +import { CippWizardVacationConfirmation } from "../../../../../components/CippWizard/CippWizardVacationConfirmation"; + +const Page = () => { + const steps = [ + { + title: "Step 1", + description: "Tenant Selection", + component: CippTenantStep, + componentProps: { + allTenants: false, + type: "single", + }, + }, + { + title: "Step 2", + description: "User Selection", + component: CippWizardAutoComplete, + componentProps: { + title: "Select the users to apply vacation mode for", + name: "Users", + placeholder: "Select Users", + type: "multiple", + api: { + url: "/api/ListGraphRequest", + dataKey: "Results", + queryKey: "Users - {tenant}", + data: { + Endpoint: "users", + manualPagination: true, + $select: "id,userPrincipalName,displayName", + $count: true, + $orderby: "displayName", + $top: 999, + }, + addedField: { + userPrincipalName: "userPrincipalName", + }, + labelField: (option) => `${option.displayName} (${option.userPrincipalName})`, + valueField: "userPrincipalName", + }, + }, + }, + { + title: "Step 3", + description: "Vacation Actions", + component: CippWizardVacationActions, + }, + { + title: "Step 4", + description: "Schedule", + component: CippWizardVacationSchedule, + }, + { + title: "Step 5", + description: "Review & Submit", + component: CippWizardVacationConfirmation, + }, + ]; + + const initialState = { + tenantFilter: null, + Users: [], + enableCAExclusion: false, + PolicyId: null, + excludeLocationAuditAlerts: false, + enableMailboxPermissions: false, + delegates: [], + permissionTypes: [], + autoMap: true, + includeCalendar: false, + calendarPermission: null, + canViewPrivateItems: false, + enableOOO: false, + oooInternalMessage: null, + oooExternalMessage: null, + startDate: null, + endDate: null, + postExecution: [], + reference: null, + }; + + return ( + + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; diff --git a/src/pages/identity/administration/vacation-mode/index.js b/src/pages/identity/administration/vacation-mode/index.js new file mode 100644 index 000000000000..1b85952156e1 --- /dev/null +++ b/src/pages/identity/administration/vacation-mode/index.js @@ -0,0 +1,108 @@ +import { Layout as DashboardLayout } from "../../../../layouts/index.js"; +import CippTablePage from "../../../../components/CippComponents/CippTablePage"; +import { Delete } from "@mui/icons-material"; +import { EyeIcon } from "@heroicons/react/24/outline"; +import { Button } from "@mui/material"; +import Link from "next/link"; +import { EventAvailable } from "@mui/icons-material"; + +const Page = () => { + const actions = [ + { + label: "View Task Details", + link: "/cipp/scheduler/task?id=[RowKey]", + icon: , + }, + { + label: "Cancel Vacation Mode", + type: "POST", + url: "/api/RemoveScheduledItem", + data: { ID: "RowKey" }, + confirmText: + "Are you sure you want to cancel this vacation mode entry? This might mean the user will remain in vacation mode permanently.", + icon: , + multiPost: false, + }, + ]; + + 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", + }, + { + filterName: "CA Exclusion", + value: [{ id: "Name", value: "CA Exclusion" }], + type: "column", + }, + { + filterName: "Mailbox Permissions", + value: [{ id: "Name", value: "Mailbox Vacation" }], + type: "column", + }, + { + filterName: "Out of Office", + value: [{ id: "Name", value: "OOO Vacation" }], + type: "column", + }, + ]; + + return ( + } + > + Add Vacation Schedule + + } + title="Vacation Mode" + apiUrl="/api/ListScheduledItems?SearchTitle=*Vacation*" + queryKey="VacationMode" + tenantInTitle={false} + actions={actions} + simpleColumns={[ + "Tenant", + "Name", + "Reference", + "TaskState", + "ScheduledTime", + "ExecutedTime", + ]} + filters={filterList} + offCanvas={{ + extendedInfoFields: [ + "Name", + "TaskState", + "ScheduledTime", + "Reference", + "Tenant", + "ExecutedTime", + ], + actions: actions, + }} + /> + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; diff --git a/src/pages/tenant/administration/tenants/groups/add.js b/src/pages/tenant/administration/tenants/groups/add.js deleted file mode 100644 index 229313fb75ad..000000000000 --- a/src/pages/tenant/administration/tenants/groups/add.js +++ /dev/null @@ -1,53 +0,0 @@ -import { Layout as DashboardLayout } from "../../../../../layouts/index.js"; -import { useForm } from "react-hook-form"; -import { ApiPostCall } from "../../../../../api/ApiCall"; -import { Box } from "@mui/material"; -import { Grid } from "@mui/system"; -import CippPageCard from "../../../../../components/CippCards/CippPageCard"; -import { CippApiResults } from "../../../../../components/CippComponents/CippApiResults"; -import CippAddEditTenantGroups from "../../../../../components/CippComponents/CippAddEditTenantGroups"; - -const Page = () => { - const formControl = useForm({ - mode: "onChange", - }); - - const addGroupApi = ApiPostCall({ - urlFromData: true, - relatedQueryKeys: ["TenantGroupListPage"], - }); - - const handleAddGroup = (data) => { - addGroupApi.mutate({ - url: "/api/EditTenantGroup", - data: { - Action: "AddEdit", - groupName: data.groupName, - groupDescription: data.groupDescription, - }, - }); - }; - - return ( - - - - - - - - - - - - ); -}; - -Page.getLayout = (page) => {page}; - -export default Page; diff --git a/src/pages/tenant/conditional/deploy-vacation/index.js b/src/pages/tenant/conditional/deploy-vacation/index.js index 14034536f6a5..cce34f8d2dc7 100644 --- a/src/pages/tenant/conditional/deploy-vacation/index.js +++ b/src/pages/tenant/conditional/deploy-vacation/index.js @@ -1,87 +1,22 @@ import { Layout as DashboardLayout } from "../../../../layouts/index.js"; -import CippTablePage from "../../../../components/CippComponents/CippTablePage"; -import { Delete } from "@mui/icons-material"; -import { EyeIcon } from "@heroicons/react/24/outline"; -import { CippAddVacationModeDrawer } from "../../../../components/CippComponents/CippAddVacationModeDrawer"; +import { Alert, Box, Button } from "@mui/material"; +import Link from "next/link"; const Page = () => { - const actions = [ - { - label: "View Task Details", - link: "/cipp/scheduler/task?id=[RowKey]", - icon: , - }, - { - label: "Cancel Vacation Mode", - type: "POST", - url: "/api/RemoveScheduledItem", - data: { ID: "RowKey" }, - confirmText: - "Are you sure you want to cancel this vacation mode entry? This might mean the user will remain in vacation mode permanently.", - icon: , - multiPost: false, - }, - ]; - - 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", - }, - ]; - return ( - - - - } - title="Vacation Mode" - apiUrl="/api/ListScheduledItems?SearchTitle=*CA Exclusion Vacation*" - queryKey="VacationMode" - tenantInTitle={false} - actions={actions} - simpleColumns={[ - "Tenant", - "Name", - "Parameters.Member", - "Reference", - "TaskState", - "ScheduledTime", - "ExecutedTime", - ]} - filters={filterList} - offCanvas={{ - extendedInfoFields: [ - "Name", - "TaskState", - "ScheduledTime", - "Parameters.Member", - "Reference", - "Parameters.PolicyId", - "Tenant", - "ExecutedTime", - ], - actions: actions, - }} - /> + + + Vacation Mode has moved to{" "} + Identity Management → Administration → Vacation Mode. + + + ); }; diff --git a/src/pages/tenant/gdap-management/index.js b/src/pages/tenant/gdap-management/index.js index 915caff047a0..9aafd4a40531 100644 --- a/src/pages/tenant/gdap-management/index.js +++ b/src/pages/tenant/gdap-management/index.js @@ -167,7 +167,7 @@ const Page = () => { - - setSelectedPolicies(selectedRows)} - /> - - - - )} - - - - ); -}; - -RecoverPoliciesPage.getLayout = (page) => {page}; - -export default RecoverPoliciesPage; diff --git a/src/pages/tenant/tools/graph-explorer/index.js b/src/pages/tenant/tools/graph-explorer/index.js index 3efb93604479..2cbbd17a3ba1 100644 --- a/src/pages/tenant/tools/graph-explorer/index.js +++ b/src/pages/tenant/tools/graph-explorer/index.js @@ -75,6 +75,7 @@ const Page = () => { code={JSON.stringify(jsonData, null, 2)} editorHeight="calc(100vh - 260px)" showLineNumbers={true} + readOnly={true} /> diff --git a/src/utils/cipp-license-backfill-manager.js b/src/utils/cipp-license-backfill-manager.js new file mode 100644 index 000000000000..8df50b94d83b --- /dev/null +++ b/src/utils/cipp-license-backfill-manager.js @@ -0,0 +1,160 @@ +/** + * Global license backfill manager + * Tracks missing licenses and triggers batch API calls to fetch them + */ + +import { getMissingFromCache, addLicensesToCache } from "./cipp-license-cache"; + +class LicenseBackfillManager { + constructor() { + this.pendingSkuIds = new Set(); + this.isBackfilling = false; + this.backfillTimeout = null; + this.callbacks = new Set(); + this.BATCH_DELAY = 500; // Wait 500ms to batch multiple requests + } + + /** + * Add a callback to be notified when backfill completes + */ + addCallback(callback) { + this.callbacks.add(callback); + return () => this.callbacks.delete(callback); + } + + /** + * Notify all callbacks + */ + notifyCallbacks() { + this.callbacks.forEach((callback) => { + try { + callback(); + } catch (error) { + console.error("Error in backfill callback:", error); + } + }); + } + + /** + * Add missing skuIds to the queue + */ + addMissingSkuIds(skuIds) { + if (!Array.isArray(skuIds)) return; + + let added = false; + skuIds.forEach((skuId) => { + if (skuId && !this.pendingSkuIds.has(skuId)) { + this.pendingSkuIds.add(skuId); + added = true; + } + }); + + if (added && !this.isBackfilling) { + this.scheduleBatchBackfill(); + } + } + + /** + * Schedule a batch backfill with debouncing + */ + scheduleBatchBackfill() { + // Clear existing timeout to debounce + if (this.backfillTimeout) { + clearTimeout(this.backfillTimeout); + } + + // Schedule new backfill + this.backfillTimeout = setTimeout(() => { + this.executeBatchBackfill(); + }, this.BATCH_DELAY); + } + + /** + * Execute the batch backfill + */ + async executeBatchBackfill() { + if (this.isBackfilling || this.pendingSkuIds.size === 0) { + return; + } + + // Get all pending skuIds + const skuIdsToFetch = Array.from(this.pendingSkuIds); + this.pendingSkuIds.clear(); + this.isBackfilling = true; + + try { + // Import axios dynamically to avoid circular dependencies + const axios = (await import("axios")).default; + const { buildVersionedHeaders } = await import("./cippVersion"); + + console.log(`[License Backfill] Fetching ${skuIdsToFetch.length} licenses...`); + + const response = await axios.post( + "/api/ExecLicenseSearch", + { skuIds: skuIdsToFetch }, + { headers: await buildVersionedHeaders() } + ); + + if (response.data && Array.isArray(response.data)) { + console.log(`[License Backfill] Received ${response.data.length} licenses`); + addLicensesToCache(response.data); + + // Notify all callbacks that backfill completed + this.notifyCallbacks(); + } + } catch (error) { + console.error("[License Backfill] Error fetching licenses:", error); + + // Re-add failed skuIds back to pending if we want to retry + // Commenting this out to avoid infinite retry loops + // skuIdsToFetch.forEach(skuId => this.pendingSkuIds.add(skuId)); + } finally { + this.isBackfilling = false; + + // If more skuIds were added during backfill, schedule another batch + if (this.pendingSkuIds.size > 0) { + this.scheduleBatchBackfill(); + } + } + } + + /** + * Check skuIds and add missing ones to backfill queue + */ + checkAndQueueMissing(skuIds) { + const missing = getMissingFromCache(skuIds); + if (missing.length > 0) { + this.addMissingSkuIds(missing); + return true; + } + return false; + } + + /** + * Get current backfill status + */ + getStatus() { + return { + isBackfilling: this.isBackfilling, + pendingCount: this.pendingSkuIds.size, + }; + } + + /** + * Clear all pending requests (useful for cleanup/testing) + */ + clear() { + if (this.backfillTimeout) { + clearTimeout(this.backfillTimeout); + this.backfillTimeout = null; + } + this.pendingSkuIds.clear(); + this.isBackfilling = false; + this.callbacks.clear(); + } +} + +// Global singleton instance +const licenseBackfillManager = new LicenseBackfillManager(); + +export default licenseBackfillManager; diff --git a/src/utils/cipp-license-cache.js b/src/utils/cipp-license-cache.js new file mode 100644 index 000000000000..089a09a7d4ef --- /dev/null +++ b/src/utils/cipp-license-cache.js @@ -0,0 +1,109 @@ +/** + * License cache manager for dynamically loaded licenses + * Uses localStorage to permanently cache licenses fetched from the API + * Cache only grows (appends missing licenses) and never expires + */ + +const CACHE_KEY = "cipp_dynamic_licenses"; +const CACHE_VERSION = "1.0"; + +/** + * Get the license cache from localStorage + * @returns {Object} Cache object with version, timestamp, and licenses map + */ +const getCache = () => { + try { + const cached = localStorage.getItem(CACHE_KEY); + if (!cached) { + return { version: CACHE_VERSION, timestamp: Date.now(), licenses: {} }; + } + + const parsed = JSON.parse(cached); + + // Check cache version - clear if outdated + if (parsed.version !== CACHE_VERSION) { + localStorage.removeItem(CACHE_KEY); + return { version: CACHE_VERSION, timestamp: Date.now(), licenses: {} }; + } + + return parsed; + } catch (error) { + console.error("Error reading license cache:", error); + return { version: CACHE_VERSION, timestamp: Date.now(), licenses: {} }; + } +}; + +/** + * Save the license cache to localStorage + * @param {Object} cache - Cache object to save + */ +const saveCache = (cache) => { + try { + localStorage.setItem(CACHE_KEY, JSON.stringify(cache)); + } catch (error) { + console.error("Error saving license cache:", error); + } +}; + +/** + * Get a license from the cache by skuId + * @param {string} skuId - The license skuId (GUID) + * @returns {string|null} The display name if found, null otherwise + */ +export const getCachedLicense = (skuId) => { + if (!skuId) return null; + + const cache = getCache(); + return cache.licenses[skuId.toLowerCase()] || null; +}; + +/** + * Add licenses to the cache + * @param {Array} licenses - Array of license objects with skuId and displayName + */ +export const addLicensesToCache = (licenses) => { + if (!Array.isArray(licenses) || licenses.length === 0) return; + + const cache = getCache(); + + licenses.forEach((license) => { + if (license.skuId && license.displayName) { + cache.licenses[license.skuId.toLowerCase()] = license.displayName; + } + }); + + cache.timestamp = Date.now(); + saveCache(cache); +}; + +/** + * Check if licenses exist in cache + * @param {Array} skuIds - Array of skuIds to check + * @returns {Array} Array of skuIds that are NOT in cache + */ +export const getMissingFromCache = (skuIds) => { + if (!Array.isArray(skuIds) || skuIds.length === 0) return []; + + const cache = getCache(); + return skuIds.filter((skuId) => !cache.licenses[skuId.toLowerCase()]); +}; + +/** + * Clear the entire license cache + */ +export const clearLicenseCache = () => { + try { + localStorage.removeItem(CACHE_KEY); + } catch (error) { + console.error("Error clearing license cache:", error); + } +}; + +/** + * Get all cached licenses + * @returns {Object} Map of skuId -> displayName + */ +export const getAllCachedLicenses = () => { + const cache = getCache(); + return cache.licenses; +}; diff --git a/src/utils/get-cipp-formatting.js b/src/utils/get-cipp-formatting.js index cf72f6e4747e..538116993b53 100644 --- a/src/utils/get-cipp-formatting.js +++ b/src/utils/get-cipp-formatting.js @@ -106,11 +106,19 @@ export const getCippFormatting = (data, cellName, type, canReceive, flatten = tr if (Array.isArray(data)) { return isText ? data.join(", ") : renderChipList(data); } else { - return isText ? ( - data - ) : ( - - ); + if (isText) return data.label ?? data; + const label = data.label ?? data; + const severityColor = { + info: "info", + warn: "warning", + warning: "warning", + error: "error", + critical: "error", + alert: "warning", + debug: "default", + }; + const color = severityColor[String(label).toLowerCase()] ?? "info"; + return ; } } diff --git a/src/utils/get-cipp-license-translation.js b/src/utils/get-cipp-license-translation.js index 4a85312eb95b..0397585d927a 100644 --- a/src/utils/get-cipp-license-translation.js +++ b/src/utils/get-cipp-license-translation.js @@ -1,10 +1,13 @@ import M365LicensesDefault from "../data/M365Licenses.json"; import M365LicensesAdditional from "../data/M365Licenses-additional.json"; +import { getCachedLicense } from "./cipp-license-cache"; +import licenseBackfillManager from "./cipp-license-backfill-manager"; export const getCippLicenseTranslation = (licenseArray) => { //combine M365LicensesDefault and M365LicensesAdditional to one array const M365Licenses = [...M365LicensesDefault, ...M365LicensesAdditional]; let licenses = []; + let missingSkuIds = []; if (Array.isArray(licenseArray) && typeof licenseArray[0] === "string") { return licenseArray; @@ -20,22 +23,47 @@ export const getCippLicenseTranslation = (licenseArray) => { licenseArray?.forEach((licenseAssignment) => { let found = false; + + // First, check static JSON files for (let x = 0; x < M365Licenses.length; x++) { if (licenseAssignment.skuId === M365Licenses[x].GUID) { licenses.push( M365Licenses[x].Product_Display_Name ? M365Licenses[x].Product_Display_Name - : licenseAssignment.skuPartNumber + : licenseAssignment.skuPartNumber, ); found = true; break; } } + + // Second, check dynamic cache + if (!found && licenseAssignment.skuId) { + const cachedName = getCachedLicense(licenseAssignment.skuId); + if (cachedName) { + licenses.push(cachedName); + found = true; + } + } + + // Finally, fall back to skuPartNumber, then skuId, then "Unknown License" if (!found) { - licenses.push(licenseAssignment.skuPartNumber); + const fallbackName = + licenseAssignment.skuPartNumber || licenseAssignment.skuId || "Unknown License"; + licenses.push(fallbackName); + + // Queue this skuId for backfill if we have it + if (licenseAssignment.skuId) { + missingSkuIds.push(licenseAssignment.skuId); + } } }); + // Trigger backfill for missing licenses + if (missingSkuIds.length > 0) { + licenseBackfillManager.addMissingSkuIds(missingSkuIds); + } + if (!licenses || licenses.length === 0) { return ["No Licenses Assigned"]; } diff --git a/yarn.lock b/yarn.lock index 33f7f0fb0628..605fa06ca1d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6655,10 +6655,10 @@ raf@^3.4.1: dependencies: performance-now "^2.1.0" -react-apexcharts@1.7.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/react-apexcharts/-/react-apexcharts-1.7.0.tgz#bbd08425674224adb27c9f2c62477d43bd5de539" - integrity sha512-03oScKJyNLRf0Oe+ihJxFZliBQM9vW3UWwomVn4YVRTN1jsIR58dLWt0v1sb8RwJVHDMbeHiKQueM0KGpn7nOA== +react-apexcharts@1.9.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/react-apexcharts/-/react-apexcharts-1.9.0.tgz#507350f4e17b64b8a1e35a4de485411456cd4676" + integrity sha512-DDBzQFuKdwyCEZnji1yIcjlnV8hRr4VDabS5Y3iuem/WcTq6n4VbjWPzbPm3aOwW4I+rf/gA3zWqhws4z9CwLw== dependencies: prop-types "^15.8.1" @@ -6688,10 +6688,10 @@ react-copy-to-clipboard@^5.1.0: copy-to-clipboard "^3.3.1" prop-types "^15.8.1" -react-dom@19.2.3: - version "19.2.3" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.3.tgz#f0b61d7e5c4a86773889fcc1853af3ed5f215b17" - integrity sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg== +react-dom@19.2.4: + version "19.2.4" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.4.tgz#6fac6bd96f7db477d966c7ec17c1a2b1ad8e6591" + integrity sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ== dependencies: scheduler "^0.27.0" @@ -6712,20 +6712,20 @@ react-dropzone@14.3.8: file-selector "^2.1.0" prop-types "^15.8.1" -react-error-boundary@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-6.1.0.tgz#d2965de0723d65d60d20aef2e120bd2c171ae4d7" - integrity sha512-02k9WQ/mUhdbXir0tC1NiMesGzRPaCsJEWU/4bcFrbY1YMZOtHShtZP6zw0SJrBWA/31H0KT9/FgdL8+sPKgHA== +react-error-boundary@^6.1.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-6.1.1.tgz#491d655e86c32434ede852755bb649119fdddd89" + integrity sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w== react-fast-compare@^2.0.1: version "2.0.4" resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9" integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw== -react-grid-layout@^1.5.0: - version "1.5.3" - resolved "https://registry.yarnpkg.com/react-grid-layout/-/react-grid-layout-1.5.3.tgz#802de040616c443b0162d73cecde792cb5beeaa2" - integrity sha512-KaG6IbjD6fYhagUtIvOzhftXG+ViKZjCjADe86X1KHl7C/dsBN2z0mi14nbvZKTkp0RKiil9RPcJBgq3LnoA8g== +react-grid-layout@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/react-grid-layout/-/react-grid-layout-2.2.2.tgz#8fa1802ffafc21c5aeb087b75809acaac53071a4" + integrity sha512-yNo9pxQWoxHWRAwHGSVT4DEGELYPyQ7+q9lFclb5jcqeFzva63/2F72CryS/jiTIr/SBIlTaDdyjqH+ODg8oBw== dependencies: clsx "^2.1.1" fast-equals "^4.0.3" @@ -6734,10 +6734,10 @@ react-grid-layout@^1.5.0: react-resizable "^3.0.5" resize-observer-polyfill "^1.5.1" -react-hook-form@^7.71.1: - version "7.71.1" - resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.71.1.tgz#6a758958861682cf0eb22131eead684ba3618f66" - integrity sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w== +react-hook-form@^7.71.2: + version "7.71.2" + resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.71.2.tgz#a5f1d2b855be9ecf1af6e74df9b80f54beae7e35" + integrity sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA== react-hot-toast@2.6.0: version "2.6.0" @@ -6908,10 +6908,10 @@ react-window@^2.2.5: resolved "https://registry.yarnpkg.com/react-window/-/react-window-2.2.5.tgz#425a29609980083aafd5a48a1711a2af9319c1d2" integrity sha512-6viWvPSZvVuMIe9hrl4IIZoVfO/npiqOb03m4Z9w+VihmVzBbiudUrtUqDpsWdKvd/Ai31TCR25CBcFFAUm28w== -react@19.2.3: - version "19.2.3" - resolved "https://registry.yarnpkg.com/react/-/react-19.2.3.tgz#d83e5e8e7a258cf6b4fe28640515f99b87cd19b8" - integrity sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA== +react@19.2.4: + version "19.2.4" + resolved "https://registry.yarnpkg.com/react/-/react-19.2.4.tgz#438e57baa19b77cb23aab516cf635cd0579ee09a" + integrity sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ== readable-stream@^2.0.2: version "2.3.8"